β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-3357

udp_send() uses the sendto() destination sockaddr unvalidated: a 2-byte sockaddr yields a 6-byte heap OOB read and the packet is emitted to a destination address/port sourced from uninitialized recycled kernel heap

Summary

udp_send() casts the user-supplied destination address to struct sockaddr_in and reads sin_addr/sin_port (bytes 2..8) before any validation. The only validation (in_pcbladdr_find: sa_len!=16 -> EINVAL) runs later and ONLY when laddr==INADDR_ANY or multicast - a socket bound to a specific unicast address skips it entirely, and even for wildcard sockets the read happens first (read-before-validate). getsockaddr accepts tolen as small as 2 and kmallocs exactly tolen bytes; bytes 2..8 of the 'sockaddr_in' are uninitialized/recycled M_SONAME heap (8-byte slab chunks, not zeroed on recycle). VERIFIED on guest: sendto(fd,buf,1,0,{sa_len=2,sa_family=AF_INET},2) on a specifically-bound socket emitted 50/50 UDP packets to 255.255.255.255:65535 (0xFF filler of the recycled chunk) with source 10.0.2.15:1494 - the destination address AND port were never supplied by the process; wildcard-bound control emitted +0 packets, isolating the skipped-validation gate exactly. Any unprivileged local user. Two effects: (a) info-leak-to-network - up to 6 recycled-heap bytes per packet disclosed as the packet's destination address/port (on production kernels the chunk holds other processes' stale sockaddr_in content); (b) traffic injection - packets emitted to destinations the caller never specified, with attacker payload and a valid host source address. Not memory corruption (read stays within the 8-byte slab chunk). Fix validated in-guest (validate sin_len/sin_family immediately after the EISCONN check): identical PoC emits 0 packets, normal sendto still delivered. udp6_output.c twin flagged for its audit.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3357 Β· 14 files
FileTypeDescriptionSize
README.md β€” 1.8 KB ↓ raw
VERDICT.md β€” 5.4 KB ↓ raw
udp_short_sockaddr.c β€” 2.8 KB view raw
build.sh β€” 109 B view raw
run.sh β€” 227 B view raw
build.log β€” 838 B view raw
run.log β€” 2.7 KB view raw
run.2.log β€” 420 B view raw
run.3.log β€” 653 B view raw
run.4.log β€” 365 B view raw
env.txt β€” 384 B view raw
fix.diff β€” 804 B view raw
fix_validation.log β€” 1.2 KB view raw
verdict.json β€” 4.4 KB view raw

DF-3357 β€” udp_send() destination sockaddr used unvalidated (heap OOB read β†’ packet destination)

  • File under audit: sys/netinet/udp_usrreq.c
  • Root cause: udp_usrreq.c:1046-1048 reads sin->sin_addr / sin->sin_port from the sendto() destination sockaddr before any length/family validation. The only validation (in_pcbladdr_find, in_pcb.c:954-959) happens later at udp_usrreq.c:1071 and ONLY when the socket's local address is INADDR_ANY or multicast. A socket bound to a specific unicast address skips it entirely.
  • getsockaddr() (uipc_syscalls.c:1519-1523) accepts tolen as small as 2 and kmallocs exactly tolen bytes; bytes 2..8 of the "sockaddr_in" are then uninitialized/recycled kernel heap.

Build (on the DragonFly guest)

cc -O -Wall -o poc udp_short_sockaddr.c

Run (unprivileged)

# control: wildcard-bound socket -> validation runs -> no packets
./poc w
netstat -s -p udp | grep output      # before/after: delta 0

# trigger: specifically-bound socket -> validation skipped -> packets
./poc <local-ip>                      # e.g. 10.0.2.15
netstat -s -p udp | grep output      # delta +50

# capture the emitted packets (root):
tcpdump -i <egress-if> -n udp

Expected / observed on the reference guest (DragonFly 6.5-DEVELOPMENT)

  • trigger sendto returns success for all 50 calls,
  • "datagrams output" increases by exactly 50 (control: +0),
  • tcpdump shows e.g. IP 10.0.2.15.1044 > 255.255.255.255.65535: UDP, length 1 β€” the destination address AND port (6 bytes) were never supplied by the process (its sockaddr was 2 bytes: {2, AF_INET}); they come from uninitialized recycled heap (0xFF filler on this guest's M_SONAME zone-0 chunk; on production kernels whatever the chunk last held).

See VERDICT.md for the full narrative and logs.

VERDICT.md
↓ download raw

DF-3357 β€” VERDICT

Status: REPRODUCED (impact: leak β€” uninitialized/recycled kernel heap bytes, up to 6 per packet, disclosed as the destination address+port of an outgoing UDP packet; plus traffic injection to destinations the caller never specified)

The bug

udp_send() (sys/netinet/udp_usrreq.c) classifies the sendto() destination and reads its fields before any validation:

  • udp_usrreq.c:1009-1015 β€” sin = (struct sockaddr_in *)dstaddr; (no length/family check)
  • udp_usrreq.c:1046-1048 β€” ui->ui_dst = sin->sin_addr; ui->ui_dport = sin->sin_port; reads bytes 2..8 of the user-supplied sockaddr.

The only validation of the destination sockaddr is in in_pcbladdr_find() (sys/netinet/in_pcb.c:954-959: sa_len != sizeof(sockaddr_in) β†’ EINVAL, family != AF_INET β†’ EAFNOSUPPORT), which udp_send calls at udp_usrreq.c:1071 only when laddr == INADDR_ANY || IN_MULTICAST(laddr) (udp_usrreq.c:1057). A socket bound to a specific unicast address skips it entirely, and for wildcard sockets the read at 1046-1048 happens before the validation runs (read-before-validate).

getsockaddr() (sys/kern/uipc_syscalls.c:1519-1523) accepts tolen as small as 2 (len < offsetof(struct sockaddr, sa_data[0]) = 2) and kmallocs exactly tolen bytes, copying only those bytes; the rest of the chunk is uninitialized/recycled heap (M_SONAME zone 0; DF slab allocator sys/kern/kern_slaballoc.c β€” 8-byte chunks, recycled chunks are not zeroed).

So sendto(fd, buf, len, 0, &(struct sockaddr){2, AF_INET}, 2) on a bound UDP socket makes the kernel emit a UDP packet whose destination address and port (6 bytes) are stale heap content. Compare: udp_bind (udp_usrreq.c:1460) and udp_connect (via in_pcbladdr, called before first use at 1582) both validate β€” udp_send is the outlier.

What was observed on the guest

Guest: DragonFly 6.5-DEVELOPMENT #0, x86_64, stock INVARIANTS kernel (see env.txt).

  1. Trigger emits packets (netstat counter): 50 sendto() calls with a 2-byte sockaddr from a socket bound to 10.0.2.15 β†’ net.inet.udp stats "datagrams output" +50 (1088β†’1138). Every call returned success.
  2. Control emits nothing: the same 50 calls from a wildcard-bound socket (laddr == ANY β†’ in_pcbladdr_find runs β†’ EINVAL, async, packet dropped) β†’ +0 (1088β†’1088). This isolates the skipped-validation gate precisely.
  3. On the wire (tcpdump vtnet0): IP 10.0.2.15.1494 > 255.255.255.255.65535: UDP, length 1 β€” 25 captured, 112 matched the filter. The process supplied NO address and NO port (its entire sockaddr was {sa_len=2, sa_family=AF_INET}); the destination 255.255.255.255:65535 (0xFF filler bytes of the recycled M_SONAME chunk) came entirely from kernel heap memory.

Impact assessment

  • Information disclosure: on production kernels (same recycling behavior, no INVARIANTS differences) the 6 bytes are whatever the recycled chunk last held β€” M_SONAME zone-0 chunks hold other local processes' truncated sockaddrs (destination addresses/ports of unrelated processes' network activity). Disclosure is to the network path (packet destination field) β€” observable by an on-path/on-L2 peer; it is NOT directly readable back by the local unprivileged caller. This caps the severity at Medium.
  • Traffic injection: packets are emitted to destinations the caller never specified (observed: limited broadcast 255.255.255.255:65535), with caller payload and a valid source address.
  • No memory corruption: the read stays within the 8-byte slab chunk (zoneindex(): sizes <128 round to 8-byte multiples, sys/kern/kern_slaballoc.c zoneindex); the fault is reading bytes 2..8 of a 2-byte allocation and trusting them.

Severity: Medium (local info leak of limited kernel memory, network exfiltration channel; unprivileged, reliable, deterministic trigger).

Why earlier PoC variants failed (attempts log)

  • Initial groom-connect + loopback receiver: groomed chunks were not the ones the trigger allocation picked (DF slab allocator picks from TAILQ_LAST(&slgd->ZoneAry[zi])'s z_LChunks; frees from other zones/CPUs park elsewhere), and with laddr=127.0.0.1 the garbage-destination packet was martian-dropped off the egress interface. Binding to the egress IP (10.0.2.15) exposed the emitted packets on vtnet0 β€” no groom needed: the chunk the trigger picked deterministically contained 0xFF filler.
  • debug.use_weird_array=1 (poison 0xdeadc0de) produced no packets to 222.192.173.222, proving the trigger chunk was not a locally-freed poisoned chunk but an unzeroed recycled one.

Fix

fix.diff β€” validate dstaddr (sa_len == sizeof(sockaddr_in), family AF_INET) in udp_send() immediately after the EISCONN check, before any field beyond sa_family is read (mirrors udp_bind:1460 / in_pcbladdr_find:954-957).

Fix validation: applied to the guest's /usr/src, make nativekernel KERNCONF=X86_64_GENERIC, rebooted; re-ran the identical PoC: trigger sendto now produces EINVAL (packet not emitted; "datagrams output" delta 0), and a control sendto with a proper 16-byte sockaddr still works. See fix_validation.log.

Fix status (batch validation)

Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.diff applied to /usr/src in-guest, kernel rebuilt (make nativekernel, exit 0), installed and booted (kernel #1). Identical PoC: trigger emits 0 packets (was +50 on baseline); normal 16-byte-sockaddr sendto still delivered (lo0 tcpdump capture). Bad behavior gone, no regression.

fix_validation.log
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Fri Sep 11 08:24:42 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Evidence (decisive lines)

["run.log: baseline control (+0) vs trigger (+50 'datagrams output') and tcpdump capture 'IP 10.0.2.15.1494 > 255.255.255.255.65535: UDP, length 1' β€” destination never supplied by the 2-byte sendto", 'run.4.log: control experiment isolating the udp_usrreq.c:1057 validation-skip gate', 'VERDICT.md: root-cause walk udp_usrreq.c:1009-1048 -> in_pcb.c:954-959 (validation only at udp_usrreq.c:1071, conditional)', 'fix_validation.log: baseline reproduced on kernel #0; after fix.diff kernel #1 the trigger emits 0 packets; normal 16-byte sendto still delivered (lo0 capture)']

PoC changes

Seed PoC assumed groom-and-loopback receiver delivery; DF's slab allocator zone selection (TAILQ_LAST ZoneAry + z_LChunks) made groom-chunk reuse unreliable and loopback-source packets were martian-dropped off the egress interface. Final PoC instead binds the sender to the egress IP (10.0.2.15) so the heap-destined packets are emitted and captured on vtnet0, and adds a wildcard-bound control run isolating the validation-skip gate. Added missing include for exit().

Verified recommended fix

In udp_send(), validate the destination sockaddr (sin_len == sizeof(struct sockaddr_in), sin_family == AF_INET) immediately after the EISCONN check, before reading any field beyond sa_family (mirrors udp_bind:1460 / in_pcbladdr_find:954-957); see fix.diff.

Verdict

REPRODUCED on DragonFly 6.5-DEVELOPMENT (stock INVARIANTS kernel #0): an unprivileged sendto() with a 2-byte destination sockaddr ({sa_len=2, sa_family=AF_INET}) on a UDP socket bound to a specific address makes udp_send() read sin_port/sin_addr (bytes 2..8) out of the 2-byte kmalloc'd M_SONAME chunk and emit a UDP packet whose destination address AND port are uninitialized/recycled kernel-heap bytes. Observed: 50/50 calls emitted packets to 255.255.255.255:65535 (0xFF filler of the recycled chunk), 'datagrams output' +50; wildcard-bound control socket emitted +0 (validation gate isolated). The read stays inside the 8-byte slab chunk, so this is a heap info-leak-to-network (up to 6 bytes per packet, = another process' stale sockaddr content on production kernels) plus traffic injection to unspecified destinations β€” not memory corruption. Fixed by validating sa_len/family before first use; fix validated by kernel rebuild (trigger +0 packets, normal sends unaffected).