Blog ·
Why UEFI netboot fails when BIOS works
UEFI clients answer PXE-E16 while BIOS boots fine from the same server. The cause is the destination address of your proxy-DHCP reply, and RFC 2131 is explicit.
By Provibr
If you have written a proxy-DHCP server, or configured one, there is a good chance you have seen this: BIOS machines netboot perfectly, and UEFI machines sit on
PXE-E16: No valid offer receivedSame server. Same network. Same boot file. The temptation is to blame the firmware, and there are enough genuinely broken UEFI implementations around that this feels reasonable.
In our case the firmware was right and we were wrong, and the difference was one field: the destination address of the reply.
The comparison that isolated it
We had a proxy-DHCP responder running next to a real DHCP server on an isolated segment. BIOS clients chainloaded and installed without complaint. UEFI clients got PXE-E16.
The measurement that mattered was swapping only the responder:
| Responder | Firmware | Boot file | Result |
|---|---|---|---|
| ours | OVMF (EDK2) | snponly.efi | PXE-E16 |
| dnsmasq | OVMF (EDK2) | snponly.efi | boots |
| ours | BIOS ROM (iPXE) | undionly.kpxe | boots |
Same VM, same firmware image, same file, same segment. Only the thing sending the offer changed. That rules out the firmware, the file, the network and the client configuration in one shot, and leaves the packet.
We were sending our proxy-DHCP offer to the directed broadcast address of the segment: 192.0.2.255 for a 192.0.2.0/24, that shape. dnsmasq sends to 255.255.255.255.
Why the client cannot accept a directed broadcast
RFC 2131 §4.1 says it outright: a server replying to a client that does not yet have an IP address must broadcast to 255.255.255.255.
The reason is not ceremony. Think about what the client knows at that moment. It has no address. It therefore has no subnet mask. Without a mask, 192.0.2.255 is just some address; the client has no way to work out that it is the broadcast address for a network it is not on yet. The limited broadcast 255.255.255.255 is the only destination that is unambiguously "everyone on this wire", to a host that knows nothing about the wire.
EDK2 implements that strictly. It sees a unicast-looking destination that is not its address, and it does not treat the packet as an offer addressed to it. Hence: no valid offer received. The firmware is doing exactly what the RFC says.
iPXE hid the bug for months
Here is the part worth internalising, because it generalises well beyond DHCP.
iPXE accepts both destinations. So does most BIOS-era PXE ROM code. That leniency meant our BIOS path worked from day one, and every test we ran (every virtual machine, because virtual machines default to BIOS or ship an iPXE ROM) passed.
The bug was not intermittent. It was not rare. It was 100% reproducible against strict firmware and 0% reproducible against everything we happened to be testing on. A permissive implementation in the default test path made a spec violation invisible for as long as we only used that path.
The fix in our code was not to correct the address at the call site. We deleted the function that could produce a directed broadcast at all, so that the wrong destination is no longer expressible. If a mistake can be made once, it can be made again by the next person; removing the tool is cheaper than remembering.
How to check yours in one command
You do not need UEFI hardware to find out. Watch the wire:
tcpdump -i br0 -n -vv 'udp port 67 or udp port 68'Look at the destination of your OFFER and ACK. If it reads anything other than 255.255.255.255 while the client is still address-less, that is your PXE-E16.
If you are writing the sender, the relevant details are: bind with SO_BROADCAST, send to 255.255.255.255:68, and pin the outgoing interface explicitly. On a machine with several bridges, "which interface does this go out of" is not a question you want the routing table answering for you.
The second one: PXE-E07 and DHCPDECLINE
A different failure with the same flavour, which we measured and deliberately chose not to fix, because fixing it would have disturbed a working path.
In proxy mode our responder also answers the DHCPREQUEST on port 67, with yiaddr 0.0.0.0, because it is a proxy and is not assigning an address. That is fine as long as its DHCP server identifier differs from the real DHCP server's.
Run the proxy on the same host as the segment's real DHCP server and both replies carry the same server identifier with different yiaddr values. EDK2 sees two ACKs claiming to be from one server, disagreeing about what address it just got, and answers with a DHCPDECLINE, which surfaces as PXE-E07.
The clean form is to answer the second round on port 4011 only, the port that exists precisely so a proxy can talk to a PXE client without colliding with the real DHCP conversation. The general rule, if you do not want to think about it: do not run a proxy-DHCP responder on the same host as the segment's DHCP server.
Other things we measured getting netboot working
Collected here because each one cost time and none of them is written down anywhere obvious.
boot.ipxe.org no longer serves the EFI binaries. snponly.efi, ipxe.efi and snp.efi are all gone; only undionly.kpxe is still there. Every tutorial that tells you to curl snponly.efi from there is stale. Get it from your distribution instead:
apt-get download ipxe
dpkg-deb -x ipxe_*.deb ./ipxe
ls ./ipxe/usr/lib/ipxe/snponly.efiRAM matters more than the installer says. A netboot initrd unpacks the installer ISO into a tmpfs, which means it lives in RAM. At 4 GB and 6 GB the boot dropped straight into a busybox prompt; at 8 GB it installed cleanly. Rule of thumb that has held for us: RAM ≥ 2 × (ISO size + 0.5 GB).
Ubuntu's installer stalls silently for about four and a half minutes on a segment with no internet uplink. subiquity tries to reach the archive before apt.fallback: offline-install takes over, and there is nothing on screen while it does. Anyone watching concludes it has hung. It has not. Wait it out.
cloud-init 26.1 truncates nocloud-net. Use ds=nocloud;s=http://…/ with the trailing slash. The semicolon needs no quoting in an iPXE script.
"Interface is up" is not "interface has carrier." A bridge waiting for a machine to be powered on has no carrier, which is precisely the state PXE exists to serve. If your preflight refuses to start on a carrier-less interface, it refuses in exactly the situation it was built for.
Your own DHCP preflight has the same bug as the thing it is checking. If you probe the segment for an existing DHCP server, send to 255.255.255.255 and include a DHCPINFORM. A server bound to its own interface address never sees the directed broadcast, and the reply to a plain DISCOVER can arrive as a raw ethernet frame that goes past your host's IP stack without a socket ever seeing it. Get both wrong and you conclude the segment is empty when it is not.
End-to-end timings, once it all worked, from power-on to login prompt on an isolated segment: Debian 13 in 2 min 30 s, Ubuntu 26.04 in 8 min 38 s (four and a half minutes of which is the stall above).
What we have not verified
Being precise about this, because it is the weakest part of the story.
What is measured is the cause: the controlled comparison above, where the only variable was the responder and therefore the destination address, reproduced against real OVMF firmware. That comparison is solid and it is the part worth taking away.
What is not yet re-verified end to end is our own fix. At the time of writing, the corrected code had not yet completed a full UEFI netboot run against real firmware: the BIOS/iPXE path was re-measured and works, the UEFI path was not. We are reporting a diagnosis we are confident in and a repair we have not finished proving.
We also did not test against real physical UEFI hardware, only OVMF. And we have not measured how other strict implementations (the network stacks in server BMCs, for instance) react to a directed broadcast. If your experience differs, we would like to hear it.
This came out of building netboot provisioning into Provibr, where the agent that runs the PXE service sits inside the customer's network and only ever connects outbound.