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)
PoC verification
Evidence pack
findings/poc/DF-3357 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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_portfrom 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.
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).
- 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. - 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.
- 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])'sz_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
fixedfix.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
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
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).
No comments yet.