DEV Community

Cover image for Your DHCP server is handing out addresses it never checked
Manoel Martins
Manoel Martins

Posted on

Your DHCP server is handing out addresses it never checked

Tested on Windows Server 2025 (24H2, build 26100.33296)

A machine boots, gets a 169.254.x.x address, and has no network. The DHCP server is up. The service is running. There are no errors in the event log. There are free addresses in the scope. Reboot the client and it works fine.

I first saw this on Windows Server 2003. I assumed it had been fixed somewhere along the way. It has not — because it was never a bug. It is a default, and the default is still there in Windows Server 2025.


What the RFC actually says

RFC 2131 splits address-conflict checking across both ends of the conversation:

the allocating server SHOULD probe the reused address before allocating the address, e.g., with an ICMP echo request, and the client SHOULD probe the newly received address, e.g., with ARP.

Two recommendations. Both SHOULD, neither MUST.

Microsoft implemented both — but ships the server half turned off. The setting is called Conflict detection attempts, it lives under IPv4 → Properties → Advanced, and its default value is 0.

The lab

Three VMs on a VMware Workstation LAN Segment — a virtual switch with no uplink and no host interface, so nothing here can reach or disturb the physical network:

Role Address MAC
DHCP server (Windows Server 2025) 172.16.0.2 (static) 00:0c:29:52:b9:9f
Squatter (Linux, static, never speaks DHCP) 172.16.0.10 00:0c:29:3a:4c:11
Client (Windows 10, WIN10-01) DHCP 00:0c:29:b8:ca:88

The scope is 172.16.0.0/24 with a deliberately tiny range, so that the address I want the server to pick is the only one it can pick. Lease duration: 8 days.

The squatter simulates what you get from a rogue DHCP server or a hand-configured host inside the scope range: something holding an address the DHCP server believes is free.


Test 1 — the default (Conflict detection attempts = 0)

Scope range: 172.16.0.10172.16.0.10.

t (s) Who What
0.000000 Client DISCOVER
0.000406 Server OFFER — 172.16.0.10
0.001285 Client REQUEST 172.16.0.10
0.003182 Server ACK 172.16.0.10
0.280785 Client ARP probe: who has 172.16.0.10? (sender 0.0.0.0)
0.280908 Squatter ARP reply: 172.16.0.10 is at 00:0c:29:3a:4c:11
3.130462 Client DHCPDECLINE
~7.3–10.3 Client ARP probes for 169.254.248.228 (APIPA)

Wireshark showing DISCOVER, OFFER of 172.16.0.10, REQUEST, ACK, the client's ARP probe, the squatter's reply, and the DHCPDECLINE

The server handed out an occupied address 406 microseconds after being asked for one. You do not need to argue about whether it checked: there is no room for a check in 406 µs. The whole capture contains zero ICMP packets and not a single ARP frame originating from the server's MAC.

Here is that claim as a filter. icmp || (arp && eth.src == <server MAC>), applied to the entire capture:

Wireshark display filter for any ICMP or any ARP originating from the DHCP server, showing zero packets displayed

The client caught it. It ARP-probed the address it had just been given, the squatter answered, and the client sent DHCPDECLINE 3.1 seconds after the ACK — then fell back to APIPA:

Autoconfiguration IPv4 Address. . : 169.254.248.228(Preferred)
Subnet Mask . . . . . . . . . . . : 255.255.0.0
Default Gateway . . . . . . . . . :
Enter fullscreen mode Exit fullscreen mode

ipconfig output on the client showing an APIPA address and no default gateway

This is the important part. The client-side safety net works, so you usually do not get a duplicate IP. You get something that is arguably worse to diagnose: a machine with no network at all, sitting next to a healthy DHCP server that has just told it everything is fine.

You get an actual duplicate address in two situations. The first is when the client does not probe at all: embedded devices, IoT, older or minimal stacks.

The second is more common and more interesting: the probe happens and nobody answers it. Detection — on either side — only works if the occupant is awake and willing to reply at the exact moment it is asked. A host that is powered off, asleep, on a link that is momentarily down, or sitting behind a fabric that suppresses ARP will be scored as absent. The address is handed out, and the duplicate appears later, when that host comes back and starts answering for an address someone else is now using.

Note that this defeats both defences equally. The client's ARP probe and the server's ICMP ping are the same bet: that silence means free. Silence does not mean free. It means nobody replied.

What the decline costs you

The declined address goes into the lease table as BAD_ADDRESS:

Client IP Address Name Lease Expiration Type
172.16.0.10 BAD_ADDRESS 9/5/2026 5:39:26 PM DHCP

DHCP console showing the BAD_ADDRESS entry for 172.16.0.10 alongside the scope's 8-day lease duration

Two things worth noticing.

The entry expires with the scope's lease duration — 8 days configured, 8 days until expiry. It is not permanent. But if the squatter is still there when it expires, the address goes back in the pool, gets offered again, gets declined again, and is marked again. It is not a leak; it is a recurring tax. Each cycle costs one unlucky client a failed boot.

The Unique ID is 0a0010ac. That is not a MAC address — it is 172.16.0.10 written as little-endian hex. The entry is a mark on the address, not a record of a client. The server does not store who claimed the conflict or who reported it. There is nothing on the server side to trace the conflict back to its source: you have to go to the switch or capture packets.


Test 2 — Conflict detection attempts = 2, same one-address scope

Same lab, one setting changed:

Set-DhcpServerv4Setting -ConflictDetectionAttempts 2
Enter fullscreen mode Exit fullscreen mode
t (s) Who What
0.000000 Client DISCOVER
0.000481 Server ICMP echo request → 172.16.0.10, TTL 255
0.000812 Squatter ICMP echo reply — address is alive

Wireshark showing the DISCOVER followed by an ICMP echo request from the DHCP server to 172.16.0.10 and the reply, with no OFFER

The server probed 481 microseconds after the DISCOVER, found the address occupied, and refused to offer it. With only one address in the scope, it then had nothing to give: nine DISCOVERs across the capture, zero OFFERs.

Correct behaviour, but not useful to a client. Which is why the scope needs a second address.

Two details worth keeping:

  • Despite attempts = 2, the server sent one ping. The reply came back in 331 µs, so a second attempt was unnecessary. The setting is a maximum, not a fixed count.
  • The probe leaves a recognisable fingerprint: ICMP echo request, TTL 255, sourced from the DHCP server's own address. If you are staring at a capture, that is how you spot Windows conflict detection.

Test 3 — Conflict detection attempts = 2, two addresses

Scope range widened to 172.16.0.10172.16.0.11.

t (s) Who What
0.000000 Client DISCOVER
0.000531 Server ICMP echo request → 172.16.0.10, TTL 255
0.000846 Squatter ICMP echo reply — occupied, skip it
0.001517 Server ARP: who has 172.16.0.11?
0.586799 Server ARP: who has 172.16.0.11?
1.593732 Server ARP: who has 172.16.0.11?
1.594280 Server OFFER — 172.16.0.11
1.595267 Client REQUEST
1.595795 Server ACK

Wireshark showing the ICMP probe to 172.16.0.10, three unanswered ARP requests for 172.16.0.11 spanning 1.59 seconds, then the OFFER of 172.16.0.11

The client came up on 172.16.0.11 with no DECLINE and no APIPA. It still ran its own ARP probes afterwards — the client-side check happens regardless — and then announced the address normally.

Where the time actually goes

This is the part I did not expect, and it is the most interesting thing in the whole exercise.

For 172.16.0.10 the server sent ICMP. For 172.16.0.11 it sent three ARP requests and no ICMP at all.

The reason is mundane and the consequence is not: the server already had an ARP cache entry for .10, so it could ping it immediately. It had no entry for .11, so it had to resolve the MAC first — and since nothing owns .11, the ARP never resolved. The ping was never sent. The server retried ARP three times, gave up after ~1.59 seconds, concluded the address was free, and offered it.

So "conflict detection by ping" only performs a ping when the address is occupied. For a genuinely free address — which is the overwhelmingly common case in production — it degenerates into an ARP resolution that has to time out. The cost is not in detecting conflicts. It is in proving their absence, on every single lease.


The trade-off, measured

Detection = 0 (default) Detection = 2
Server probes before offering No — 0 ICMP, 0 ARP from server Yes — ICMP at 481 µs, TTL 255
Occupied address offered Yes No
Who detects the conflict The client, via ARP probe The server
Outcome for the client DHCPDECLINE → APIPA 169.254.x.x Valid address, first try
Effect on the pool Address marked BAD_ADDRESS for the lease duration Address skipped, pool intact
DISCOVER → OFFER 406 µs 1.594 s

That is roughly 3,900× slower per lease. On a campus network handing out thousands of leases at 8 a.m., that is not a rounding error — and it is paid on every lease, including the overwhelming majority that would never have had a conflict.

This is not a Microsoft quirk. ISC, which implements the same check as ping-check in dhcpd, publicly questions its value for exactly the same reason: it does not scale to environments that need thousands of leases per second. Two implementations, same trade-off, and neither vendor thinks the safe option is free.


The safety net that is not there

The usual answer to "what about rogue DHCP servers?" is DHCP server authorization in Active Directory. It is worth being precise about what that actually does.

Authorization works because the Microsoft DHCP service refuses to start when it is not authorized in AD. A home router, a misconfigured access point, a VM running dnsmasq, or any appliance with DHCP enabled asks nobody for permission. AD authorization does not see them and cannot stop them.

So in the exact scenario where you need protection, both defences are absent at once: authorization does not apply to the rogue, and conflict detection is switched off.

What to actually do

  • Set conflict detection to 1 or 2 if your scopes overlap with static hosts, or if you cannot rule out rogue servers. Measure the added latency against your lease rate before rolling it out broadly — it is a real cost, not a free win.
  • Do not treat conflict detection as a fix. It is a probe, and a probe only detects hosts that are awake and answering. A machine that is powered off when the lease is granted is invisible to it, and the duplicate simply shows up when that machine wakes.
  • Fix it at layer 2. DHCP snooping on your switches stops the rogue server instead of guessing at its consequences. That is the actual solution; conflict detection is damage control after the fact.
  • Watch for BAD_ADDRESS entries. They are the only evidence the server keeps that something is wrong, they expire quietly with the lease duration, and they say nothing about the culprit.
  • When a client shows up on 169.254.x.x with a healthy DHCP server, check the lease table for BAD_ADDRESS before you blame the NIC.

Method

Lab built in VMware Workstation on a Windows host, using a LAN Segment — a virtual switch with no uplink and no host interface, fully isolated from the physical network. Server: Windows Server 2025, 24H2, build 26100.33296, DHCP role, single NIC on the lab segment. Client: Windows 10 (WIN10-01). Squatter: Linux with a static address and no DHCP client running.

Scope ranges were deliberately narrowed to one address (Tests 1 and 2) and two addresses (Test 3) so that address selection was deterministic. This is why the server runs out of addresses in Test 2, and it is a property of the lab, not of Windows.

Captures were taken unfiltered on both the server and the client side and correlated by DHCP transaction ID; timings quoted are from the server-side capture. Before each test the BAD_ADDRESS entry was deleted and the DHCP service restarted, so every run starts from an empty lease table.

Top comments (0)