xref: /freebsd/sbin/dhclient/tests/functional.py (revision a143197801c680b958ab662c88cf086668a1958b)
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