Key Takeaways
- Option 121 and option 249 carry identical bytes. RFC 3442 defines 121; Microsoft’s option 249 is the same payload under a different code. In a mixed fleet, send both.
- The whole trick is the destination descriptor — one byte of prefix length, then only the significant octets of the network. A /8 is two bytes, a /24 is four. That variable length is what breaks every attempt to type it as an IP address.
option 249 ip <network> <mask> <gateway>is not a thing. It puts twelve bytes on the wire whose first byte claims a prefix length of 192. Build the hex yourself.- A malformed option can cost you the whole lease, not just the route. Windows clients “silently discard the message if any of the options do not conform to the syntax.”
- If you send classless static routes, the client must ignore option 3 (Router). Your default gateway now has to be in the option 121 payload too, or there isn’t one.
- This is also an attack. TunnelVision (CVE-2024-3661, May 2024) decloaks routing-based VPNs using nothing but option 121 from a rogue DHCP server.
This started as a note on my old Russian blog in 2012. The problem was a university network with PPTP VPN concentrators, a few thousand staff, and internal subnets scattered across buildings. Clients took their default gateway from the VPN server, which was the wrong gateway for most of them, so the sysadmins were walking desk to desk adding route add -p lines by hand.
DHCP already has a mechanism for this. It is just encoded in a way that punishes guessing, and the original version of this post got the encoding half right. I built the encoder from the specification, then I tested its output against the worked descriptor table in RFC 3442 itself before publishing any of it, which is the only reason I am willing to hand you hex to paste into a production DHCP server.

The Catalyst 4503-E above was the default gateway and the DHCP server, which is why everything below is IOS.
121 or 249?
Both. They are the same option with two code points, for historical reasons that are entirely Microsoft’s.
A classless static route is a route carried with an explicit prefix length rather than one inferred from the address’s old Class A/B/C range, which is what lets a DHCP server hand a client a /26 or a /30 instead of guessing at /8, /16 or /24. DHCP option 121 is the standard option that carries a list of them, and option 249 is Microsoft’s identically formatted version under a different code number.
RFC 3442 standardised classless static routes as option 121 in December 2002. Microsoft had already shipped the feature as option 249, and Microsoft’s own protocol documentation is refreshingly blunt about it: “The length and the data format for the Microsoft Classless Static Route Option are exactly the same as those specified for the Classless Static Route Option in [RFC3442]; the only difference is that Option Code 249 SHOULD be used instead of or in addition to Option Code 121.”
Which code a client understands depends on its age:
- Windows XP and Server 2003 request only 249, and per Microsoft “ignore Option 121 if included in a DHCPv4 message”.
- Vista and later request both 121 and 249.
- Everything that isn’t Windows — Linux, macOS, iOS, the BSDs — implements 121 only. Android doesn’t implement it at all.
So the safe configuration is to send the same bytes under both codes. If your fleet is entirely post-XP, 121 alone is enough, and in 2026 that is most people. In 2012 it was not, which is why the original post was about 249.
The destination descriptor is the entire problem
The destination descriptor is the variable-length encoding of a single destination inside the option: one byte holding the prefix length, followed by only the octets of the network that the mask actually covers. Each route in the option is one of these followed by a four-byte next hop. The descriptor is where people come unstuck, because it is deliberately compact, and because its length changes with the prefix.
RFC 3442 spells out how many that is:
prefix length significant octets descriptor size
0 0 1 byte
1-8 1 2 bytes
9-16 2 3 bytes
17-24 3 4 bytes
25-32 4 5 bytesSo a route is between five and nine bytes, and every route in the option can be a different length. The RFC’s own worked examples:
10.0.0.0/8 -> 8.10 10.17.0.0/16 -> 16.10.17 10.0.0.0/24 -> 24.10.0.0 10.229.0.128/25 -> 25.10.229.0.128 10.198.122.47/32 -> 32.10.198.122.47
Read those as decimal bytes, not as IP addresses. 8.10 is two bytes: 08 0A. That is the point where a CLI that wants dotted quads stops being useful to you.
Why the obvious IOS syntax never worked
The Cisco DHCP pool option command takes one of three value types:
option code [instance number] {ascii string | hex {string | none} | ip {address | hostname}}The original post recommended option 249 ip (network) (mask) (gateway), and I noted at the time that /23 and /8 routes were being ignored. That was generous. The ip keyword does exactly what it says: it emits each argument as a four-byte IP address. Three arguments, twelve bytes, no descriptor anywhere. Feed those bytes to a parser that expects RFC 3442:
$ ./csr.py decode C0A8.1E00.FFFF.FF00.C0A8.0A01 csr.py: byte 0: width 192 is not a prefix length
The first byte is 192 — the leading octet of 192.168.30.0 — and there is no such thing as a /192. A client either discards the option or, worse, parses on and installs whatever falls out.
There is a way to make the ip keyword work, and it is a hack: a descriptor for a /17 through /24 is exactly four bytes, so it can be typed as a fake dotted quad. option 249 ip 24.192.168.30 192.168.10.1 puts 18 C0 A8 1E C0 A8 0A 01 on the wire, which is a correct route to 192.168.30.0/24 via 192.168.10.1. It also breaks the moment someone adds a /16, because a /16 descriptor is three bytes and there is no way to write three bytes as a quad. That is the /8 case from 2012, fully explained.
I never captured the /23 packets, so I’m not going to invent a reason for that one fourteen years later. The lesson stands either way: don’t let the CLI guess the wire format. Build the hex.
A gotcha worth more than the routes
Microsoft’s spec has a line that explains a class of “DHCP is broken” tickets: Windows DHCPv4 clients “parse each of the options in the DHCPOFFER received and silently discard the message if any of the options do not conform to the syntax.”
Not the option. The message. A botched option 249 doesn’t cost you a static route — it can cost the client its address, and the symptom is a 169.254 APIPA address with nothing useful in the event log. If you push a new option and a VLAN full of machines stops getting leases, that is where to look first.
Building the hex
The 2012 post shipped a Perl one-liner for this. It worked for the cases I was using and had a bug I only noticed writing this up: for a default route it emits a width byte and an octet, when a /0 descriptor is a single zero byte with nothing after it. Everything downstream shifts by one and the whole option becomes garbage.
Here is a version I actually trust. It refuses networks with host bits set, enforces the 255-byte option ceiling, and decodes as well as encodes — because the useful half is checking what a device is really sending.
#!/usr/bin/env python3
"""
Build and check DHCP classless static routes.
RFC 3442 calls it option 121. Microsoft calls the same bytes option 249.
./csr.py encode <gateway> <cidr> [<cidr> ...]
./csr.py decode <hex string>
"""
import ipaddress
import sys
def encode(gateway, cidrs):
gw = ipaddress.IPv4Address(gateway).packed
out = b""
for cidr in cidrs:
net = ipaddress.IPv4Network(cidr, strict=True) # refuses host bits
significant = (net.prefixlen + 7) // 8 # RFC 3442, page 4
out += bytes([net.prefixlen])
out += net.network_address.packed[:significant]
out += gw
if len(out) > 255:
raise ValueError(f"{len(out)} bytes, but a DHCP option holds 255")
return out
def decode(text):
b = bytes.fromhex("".join(c for c in text if c in "0123456789abcdefABCDEF"))
i = 0
while i < len(b):
width = b[i]
if width > 32:
raise ValueError(f"byte {i}: width {width} is not a prefix length")
i += 1
significant = (width + 7) // 8
octets = b[i:i + significant].ljust(4, b"x00")
i += significant
gw, i = b[i:i + 4], i + 4
if len(gw) != 4:
raise ValueError("truncated: ran out of bytes reading a next hop")
yield f"{ipaddress.IPv4Address(octets)}/{width} via {ipaddress.IPv4Address(gw)}"
def dotted(raw):
h = raw.hex().upper()
return ".".join(h[i:i + 4] for i in range(0, len(h), 4))
def main(argv):
try:
if argv[1] == "encode":
raw = encode(argv[2], argv[3:])
print(f"{len(raw)} bytes, {len(argv) - 3} route(s)")
print(f" option 121 hex {dotted(raw)}")
print(f" option 249 hex {dotted(raw)}")
elif argv[1] == "decode":
for route in decode(argv[2]):
print(route)
else:
raise ValueError(f"unknown mode {argv[1]!r}")
except (ValueError, IndexError) as exc:
sys.exit(f"csr.py: {exc}")
if __name__ == "__main__":
main(sys.argv)Three routes to internal subnets via the building’s own gateway:
$ ./csr.py encode 10.20.0.254 10.0.0.0/8 172.16.4.0/23 192.168.30.0/24 22 bytes, 3 route(s) option 121 hex 080A.0A14.00FE.17AC.1004.0A14.00FE.18C0.A81E.0A14.00FE option 249 hex 080A.0A14.00FE.17AC.1004.0A14.00FE.18C0.A81E.0A14.00FE
And read it back, which is the step that catches typos before a VLAN does:
$ ./csr.py decode 080A.0A14.00FE.17AC.1004.0A14.00FE.18C0.A81E.0A14.00FE 10.0.0.0/8 via 10.20.0.254 172.16.4.0/23 via 10.20.0.254 192.168.30.0/24 via 10.20.0.254
Note the three routes are 6, 7 and 9 bytes. Nothing about that stream is alignable, which is why hand-counting it at 2am is a bad plan.
On the switch
ip dhcp pool STAFF-VLAN20 network 10.20.0.0 255.255.0.0 default-router 10.20.0.254 dns-server 10.20.0.53 domain-name lab.local lease 0 8 option 121 hex 080A.0A14.00FE.17AC.1004.0A14.00FE.18C0.A81E.0A14.00FE option 249 hex 080A.0A14.00FE.17AC.1004.0A14.00FE.18C0.A81E.0A14.00FE
IOS accepts the hex as one continuous string or with bytes separated by periods, colons or spaces. The four-digit grouping above is only for human eyes; 080A0A1400FE... is the same option.
Two constraints to design around. A DHCP option carries a one-byte length, so 255 bytes is the hard ceiling — between 28 and 51 routes depending on prefix lengths. RFC 3442 anticipates overflowing it and points at option concatenation, but if you are near that limit on a DHCP option you have a routing protocol problem, not an encoding problem.
Second: RFC 3442 requires a client that receives classless static routes to ignore option 3, the Router option. Your default-router line above still goes on the wire, and a conforming client will throw it away. If these clients need a default gateway, put 0.0.0.0/0 in the payload as well:
$ ./csr.py encode 10.20.0.254 0.0.0.0/0 5 bytes, 1 route(s) option 121 hex 000A.1400.FE option 249 hex 000A.1400.FE
Five bytes: a zero width, no network octets at all, then the next hop. This is the single most common way to break a subnet with option 121 — the routes arrive, and the default gateway quietly disappears with them.
One more from the RFC that saves a hop: a next hop of 0.0.0.0 means the destination is on-link, reachable directly on the same segment. Useful when two subnets share a physical link.
Other DHCP servers
ISC dhcpd predates the option’s allocation, so you declare it yourself:
option rfc3442-classless-static-routes code 121 = array of integer 8; option ms-classless-static-routes code 249 = array of integer 8; option rfc3442-classless-static-routes 8,10, 10,20,0,254; option ms-classless-static-routes 8,10, 10,20,0,254;
Decimal bytes, same layout — the descriptor rules do not change. On the client side both dhclient and systemd-networkd handle option 121. MikroTik lets you define an arbitrary option code with a raw hex value, which is the same string the script prints — worth knowing if the lab RouterBoard is doing the addressing.
Checking it landed
On IOS, watch the offer being built:
debug ip dhcp server packet detail show ip dhcp pool STAFF-VLAN20 show ip dhcp binding
On the client, look for the routes rather than trusting the lease:
Windows: route print -4
Get-NetRoute -AddressFamily IPv4
Linux: ip route show
macOS: netstat -rn -f inetIf the routes are missing but the address is fine, the client parsed the option and rejected the contents — decode the hex. If the address is missing too, the client threw away the entire offer, and that is the malformed-option case above.
The part that didn’t exist in 2012
In May 2024 Leviathan Security published TunnelVision (CVE-2024-3661), and it is this option, used offensively. An attacker on the same LAN runs a rogue DHCP server and pushes classless static routes that are more specific than the 0.0.0.0/0 route a routing-based VPN installs on its virtual interface. Longest-prefix match does the rest: traffic leaves through the physical NIC, unencrypted, past a VPN that is still showing as connected.
It works on Windows, Linux, macOS and iOS. Android is unaffected for the least satisfying reason available — it never implemented option 121 at all.
Nothing here is a protocol flaw. DHCP has never authenticated anything, and a client that installs routes from DHCP is doing precisely what RFC 3442 asks. The mitigations are the ones you already own: DHCP snooping on the access layer so a rogue server never gets an offer through, and on the client side either network namespaces or a firewall rule dropping traffic to the physical interface while the tunnel is up.
So the option that saved a team of sysadmins a lot of walking in 2012 is now a documented VPN bypass, unchanged. That is usually how it goes with DHCP.
Sources
- RFC 3442 — The Classless Static Route Option for DHCPv4, for option 121, the destination descriptor encoding, the on-link 0.0.0.0 next hop, and the requirement to ignore the Router option
- [MS-DHCPE] 2.2.8 — DHCPv4 Option Code 249, Microsoft Classless Static Route Option, for the identical wire format
- [MS-DHCPE] Appendix A: Product Behavior, notes 20, 27 and 30 — which Windows versions request 121, 249 or both, and the silent discard of a non-conforming DHCPOFFER
- Cisco — Configuring the Cisco IOS DHCP Server, for the DHCP pool
optioncommand and its ascii, hex and ip keywords - Leviathan Security — TunnelVision (CVE-2024-3661), published 6 May 2024, for the VPN decloaking technique and its mitigations
Related: How to make a private VPN server in 10 minutes, Making Home Lab. Part 1, Cisco Config Archive & Rollback: revert vs reload, Cisco Native VLAN: How It Works and Why Mismatches Bite, NBN FTTP Connection Box Status Lights
