# 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.
