1*a1431978SMark Johnston# 2*a1431978SMark Johnston# Copyright (c) 2026 The FreeBSD Foundation 3*a1431978SMark Johnston# 4*a1431978SMark Johnston# This software was developed by Mark Johnston under sponsorship from 5*a1431978SMark Johnston# the FreeBSD Foundation. 6*a1431978SMark Johnston# 7*a1431978SMark Johnston# SPDX-License-Identifier: BSD-2-Clause 8*a1431978SMark Johnston# 9*a1431978SMark Johnston 10*a1431978SMark Johnstonimport subprocess 11*a1431978SMark Johnston 12*a1431978SMark Johnstonimport pytest 13*a1431978SMark Johnstonimport scapy.all as sp 14*a1431978SMark Johnstonfrom atf_python.sys.net.tools import ToolsHelper 15*a1431978SMark Johnstonfrom atf_python.sys.net.vnet import VnetTestTemplate 16*a1431978SMark Johnston 17*a1431978SMark Johnston 18*a1431978SMark Johnstondef mac_to_chaddr(mac_str): 19*a1431978SMark Johnston """Convert a MAC address string to a 16-byte BOOTP chaddr.""" 20*a1431978SMark Johnston mac_bytes = bytes.fromhex(mac_str.replace(":", "")) 21*a1431978SMark Johnston return mac_bytes + b"\x00" * (16 - len(mac_bytes)) 22*a1431978SMark Johnston 23*a1431978SMark Johnston 24*a1431978SMark Johnstondef get_dhcp_type(pkt): 25*a1431978SMark Johnston """Extract the DHCP message type from a packet.""" 26*a1431978SMark Johnston if not pkt.haslayer(sp.DHCP): 27*a1431978SMark Johnston return None 28*a1431978SMark Johnston for opt in pkt[sp.DHCP].options: 29*a1431978SMark Johnston if isinstance(opt, tuple) and opt[0] == "message-type": 30*a1431978SMark Johnston return sp.DHCPTypes[opt[1]] 31*a1431978SMark Johnston return None 32*a1431978SMark Johnston 33*a1431978SMark Johnston 34*a1431978SMark Johnstondef build_reply(iface, server_ip, client_ip, client_mac, xid, msg_type, 35*a1431978SMark Johnston extra_opts=None, sname=b"", file=b""): 36*a1431978SMark Johnston """Build a DHCP OFFER or ACK packet.""" 37*a1431978SMark Johnston options = [ 38*a1431978SMark Johnston ("message-type", msg_type), 39*a1431978SMark Johnston ("server_id", server_ip), 40*a1431978SMark Johnston ("lease_time", 3600), 41*a1431978SMark Johnston ("subnet_mask", "255.255.255.0"), 42*a1431978SMark Johnston ("router", server_ip), 43*a1431978SMark Johnston ] 44*a1431978SMark Johnston if extra_opts: 45*a1431978SMark Johnston options.extend(extra_opts) 46*a1431978SMark Johnston options.append("end") 47*a1431978SMark Johnston 48*a1431978SMark Johnston return ( 49*a1431978SMark Johnston sp.Ether(dst=client_mac, src=sp.get_if_hwaddr(iface)) 50*a1431978SMark Johnston / sp.IP(src=server_ip, dst="255.255.255.255") 51*a1431978SMark Johnston / sp.UDP(sport=67, dport=68) 52*a1431978SMark Johnston / sp.BOOTP(op=2, 53*a1431978SMark Johnston xid=xid, 54*a1431978SMark Johnston yiaddr=client_ip, 55*a1431978SMark Johnston siaddr=server_ip, 56*a1431978SMark Johnston chaddr=mac_to_chaddr(client_mac), 57*a1431978SMark Johnston sname=sname, 58*a1431978SMark Johnston file=file, 59*a1431978SMark Johnston ) 60*a1431978SMark Johnston / sp.DHCP(options=options) 61*a1431978SMark Johnston ) 62*a1431978SMark Johnston 63*a1431978SMark Johnston 64*a1431978SMark Johnstonclass TestDhclient(VnetTestTemplate): 65*a1431978SMark Johnston _SERVER_IP = "192.0.2.1" 66*a1431978SMark Johnston _CLIENT_IP = "192.0.2.10" 67*a1431978SMark Johnston 68*a1431978SMark Johnston TOPOLOGY = { 69*a1431978SMark Johnston "vnet1": {"ifaces": ["if1"]}, 70*a1431978SMark Johnston "vnet2": {"ifaces": ["if1"]}, 71*a1431978SMark Johnston "if1": {}, 72*a1431978SMark Johnston } 73*a1431978SMark Johnston 74*a1431978SMark Johnston def vnet1_handler(self, vnet): 75*a1431978SMark Johnston """ 76*a1431978SMark Johnston Run dhclient in vnet1 (the client side of the epair). Send 77*a1431978SMark Johnston back the output of "ifconfig <iface>" once dhclient has finished 78*a1431978SMark Johnston its exchange. 79*a1431978SMark Johnston """ 80*a1431978SMark Johnston ifname = vnet.iface_alias_map["if1"].name 81*a1431978SMark Johnston 82*a1431978SMark Johnston # Wait for the test body to be ready before starting dhclient. 83*a1431978SMark Johnston vnet.pipe.recv() 84*a1431978SMark Johnston 85*a1431978SMark Johnston try: 86*a1431978SMark Johnston result = subprocess.run( 87*a1431978SMark Johnston ["/sbin/dhclient", "-c", "/dev/null", 88*a1431978SMark Johnston "-l", "dhclient.lease", ifname], 89*a1431978SMark Johnston capture_output=True, text=True, timeout=30, 90*a1431978SMark Johnston ) 91*a1431978SMark Johnston stderr = result.stderr 92*a1431978SMark Johnston except subprocess.TimeoutExpired: 93*a1431978SMark Johnston stderr = "dhclient timed out" 94*a1431978SMark Johnston out = ToolsHelper.get_output("/sbin/ifconfig {}".format(ifname)) 95*a1431978SMark Johnston lease = "" 96*a1431978SMark Johnston try: 97*a1431978SMark Johnston with open("dhclient.lease", "r") as f: 98*a1431978SMark Johnston lease = f.read() 99*a1431978SMark Johnston except FileNotFoundError: 100*a1431978SMark Johnston pass 101*a1431978SMark Johnston vnet.pipe.send({"ifconfig": out, "stderr": stderr, "lease": lease}) 102*a1431978SMark Johnston 103*a1431978SMark Johnston def _setup_server(self): 104*a1431978SMark Johnston """Configure the server side of the epair and return its name.""" 105*a1431978SMark Johnston ifname = self.vnet.iface_alias_map["if1"].name 106*a1431978SMark Johnston ToolsHelper.print_output( 107*a1431978SMark Johnston "/sbin/ifconfig {} inet {}/24 up".format(ifname, self._SERVER_IP) 108*a1431978SMark Johnston ) 109*a1431978SMark Johnston return ifname 110*a1431978SMark Johnston 111*a1431978SMark Johnston def _do_dora(self, ifname, first_extra_opts=None, extra_opts=None, 112*a1431978SMark Johnston sname=b"", file=b""): 113*a1431978SMark Johnston """ 114*a1431978SMark Johnston Run a discover-offer-request-acknowledge exchange and 115*a1431978SMark Johnston return (discover_count, result). 116*a1431978SMark Johnston 117*a1431978SMark Johnston If first_extra_opts is set, it is used in the OFFER for the first 118*a1431978SMark Johnston DHCPDISCOVER only; subsequent DISCOVERs use extra_opts. 119*a1431978SMark Johnston """ 120*a1431978SMark Johnston discover_count = 0 121*a1431978SMark Johnston done = False 122*a1431978SMark Johnston 123*a1431978SMark Johnston def handle_dhcp(pkt): 124*a1431978SMark Johnston nonlocal discover_count, done 125*a1431978SMark Johnston 126*a1431978SMark Johnston msg_type = get_dhcp_type(pkt) 127*a1431978SMark Johnston if msg_type is None: 128*a1431978SMark Johnston return 129*a1431978SMark Johnston 130*a1431978SMark Johnston client_mac = pkt[sp.Ether].src 131*a1431978SMark Johnston xid = pkt[sp.BOOTP].xid 132*a1431978SMark Johnston 133*a1431978SMark Johnston if msg_type == "discover": 134*a1431978SMark Johnston discover_count += 1 135*a1431978SMark Johnston opts = extra_opts 136*a1431978SMark Johnston if discover_count == 1 and first_extra_opts is not None: 137*a1431978SMark Johnston opts = first_extra_opts 138*a1431978SMark Johnston reply = build_reply( 139*a1431978SMark Johnston ifname, self._SERVER_IP, self._CLIENT_IP, client_mac, 140*a1431978SMark Johnston xid, "offer", extra_opts=opts, sname=sname, file=file, 141*a1431978SMark Johnston ) 142*a1431978SMark Johnston sp.sendp(reply, iface=ifname, verbose=0) 143*a1431978SMark Johnston 144*a1431978SMark Johnston elif msg_type == "request": 145*a1431978SMark Johnston reply = build_reply( 146*a1431978SMark Johnston ifname, self._SERVER_IP, self._CLIENT_IP, client_mac, 147*a1431978SMark Johnston xid, "ack", sname=sname, file=file, 148*a1431978SMark Johnston ) 149*a1431978SMark Johnston sp.sendp(reply, iface=ifname, verbose=0) 150*a1431978SMark Johnston done = True 151*a1431978SMark Johnston 152*a1431978SMark Johnston sniffer = sp.AsyncSniffer( 153*a1431978SMark Johnston iface=ifname, 154*a1431978SMark Johnston filter="udp and dst port 67", 155*a1431978SMark Johnston prn=handle_dhcp, 156*a1431978SMark Johnston stop_filter=lambda _: done, 157*a1431978SMark Johnston started_callback=lambda: self.send_object( 158*a1431978SMark Johnston self.vnet_map["vnet1"].pipe, "go", 159*a1431978SMark Johnston ), 160*a1431978SMark Johnston ) 161*a1431978SMark Johnston sniffer.start() 162*a1431978SMark Johnston sniffer.join(timeout=30) 163*a1431978SMark Johnston 164*a1431978SMark Johnston assert done, "DHCP exchange did not complete" 165*a1431978SMark Johnston result = self.wait_object(self.vnet_map["vnet1"].pipe, timeout=15) 166*a1431978SMark Johnston return discover_count, result 167*a1431978SMark Johnston 168*a1431978SMark Johnston @pytest.mark.require_user("root") 169*a1431978SMark Johnston def test_classless_routes_truncated(self): 170*a1431978SMark Johnston """ 171*a1431978SMark Johnston Verify that dhclient rejects a DHCPOFFER containing a truncated 172*a1431978SMark Johnston classless static routes option. 173*a1431978SMark Johnston 174*a1431978SMark Johnston A malformed option 121 payload is constructed with a valid first 175*a1431978SMark Johnston entry (default route, width=0) followed by a second entry with 176*a1431978SMark Johnston width=24 that has only 1 byte of subnet data instead of the 177*a1431978SMark Johnston required 3 bytes. 178*a1431978SMark Johnston 179*a1431978SMark Johnston The test sends the bad offer first, then a valid offer, and 180*a1431978SMark Johnston verifies that dhclient rejects the bad offer and successfully 181*a1431978SMark Johnston acquires a lease from the good one. 182*a1431978SMark Johnston """ 183*a1431978SMark Johnston ifname = self._setup_server() 184*a1431978SMark Johnston bad_routes = ( 185*a1431978SMark Johnston b"\x00" # width=0 (default route) 186*a1431978SMark Johnston b"\xc0\xa8\x01\x01" # gateway: 192.168.1.1 187*a1431978SMark Johnston b"\x18" # width=24 (/24 subnet) 188*a1431978SMark Johnston b"\x0a" # only 1 of 3 required subnet octets 189*a1431978SMark Johnston ) 190*a1431978SMark Johnston discover_count, result = self._do_dora( 191*a1431978SMark Johnston ifname, first_extra_opts=[(121, bad_routes)], 192*a1431978SMark Johnston ) 193*a1431978SMark Johnston assert discover_count >= 2, \ 194*a1431978SMark Johnston "Expected dhclient to reject the first (bad) offer and retry" 195*a1431978SMark Johnston assert "inet {}".format(self._CLIENT_IP) in result["ifconfig"] 196*a1431978SMark Johnston 197*a1431978SMark Johnston @pytest.mark.require_user("root") 198*a1431978SMark Johnston def test_filename_backslash(self): 199*a1431978SMark Johnston """ 200*a1431978SMark Johnston Verify that backslashes in the BOOTP filename don't cause the 201*a1431978SMark Johnston lease to be rejected. These can appear in Windows boot paths. 202*a1431978SMark Johnston """ 203*a1431978SMark Johnston ifname = self._setup_server() 204*a1431978SMark Johnston discover_count, result = self._do_dora( 205*a1431978SMark Johnston ifname, file=b"\\windows\\boot\\pxeboot.com", 206*a1431978SMark Johnston ) 207*a1431978SMark Johnston assert discover_count == 1, \ 208*a1431978SMark Johnston "Lease with backslash in filename should be accepted" 209*a1431978SMark Johnston assert "inet {}".format(self._CLIENT_IP) in result["ifconfig"] 210*a1431978SMark Johnston 211*a1431978SMark Johnston @pytest.mark.require_user("root") 212*a1431978SMark Johnston def test_sname_valid(self): 213*a1431978SMark Johnston """ 214*a1431978SMark Johnston Verify that a valid server name is preserved in the lease file. 215*a1431978SMark Johnston """ 216*a1431978SMark Johnston ifname = self._setup_server() 217*a1431978SMark Johnston discover_count, result = self._do_dora( 218*a1431978SMark Johnston ifname, sname=b"dhcp.example.com", 219*a1431978SMark Johnston ) 220*a1431978SMark Johnston assert discover_count == 1 221*a1431978SMark Johnston assert "inet {}".format(self._CLIENT_IP) in result["ifconfig"] 222*a1431978SMark Johnston assert "dhcp.example.com" in result["lease"] 223*a1431978SMark Johnston 224*a1431978SMark Johnston @pytest.mark.require_user("root") 225*a1431978SMark Johnston def test_sname_quote(self): 226*a1431978SMark Johnston """ 227*a1431978SMark Johnston Verify that a double quote in the BOOTP server name causes the 228*a1431978SMark Johnston server name to be discarded but does not cause the lease to be 229*a1431978SMark Johnston rejected. Values from the lease file may be handled by 230*a1431978SMark Johnston dhclient-script and thus are susceptible to shell injection. 231*a1431978SMark Johnston """ 232*a1431978SMark Johnston ifname = self._setup_server() 233*a1431978SMark Johnston discover_count, result = self._do_dora( 234*a1431978SMark Johnston ifname, sname=b'evil"server', 235*a1431978SMark Johnston ) 236*a1431978SMark Johnston assert discover_count == 1, \ 237*a1431978SMark Johnston "Lease with quote in sname should be accepted" 238*a1431978SMark Johnston assert "inet {}".format(self._CLIENT_IP) in result["ifconfig"] 239*a1431978SMark Johnston assert 'evil"server' not in result["lease"], \ 240*a1431978SMark Johnston "Server name with quote should not appear in the lease file" 241*a1431978SMark Johnston assert "server-name" not in result["lease"], \ 242*a1431978SMark Johnston "Unsafe server name should be discarded entirely" 243