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.10 – 172.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) |
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:
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 . . . . . . . . . :
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 |
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
| 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 |
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.10 – 172.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 |
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_ADDRESSentries. 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.xwith a healthy DHCP server, check the lease table forBAD_ADDRESSbefore 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)