# DF-0744 — PoC evidence pack

**Finding:** `udp6_output` corrupts sticky socket options and leaks per-call
options memory when `ip6_setpktoptions` fails.
**File:** `sys/netinet6/udp6_output.c:262-267`
**Severity (per finding):** Medium · **CWE-401** (Missing Release of Memory on
Error Path) · **CWE-665** (Improper Initialization / state destruction)

## Verdict (one line)

**REPRODUCED** on DragonFly 6.5-DEVELOPMENT `#0` (unpatched audit baseline):
both effects of the bug — silent sticky-option corruption and per-call
`M_IP6OPT` leak — are demonstrated by an unprivileged user over a plain
`AF_INET6 SOCK_DGRAM` socket. **Fix VALIDATED** on a single-fix `#1`
kernel: both effects disappear.

## How to reproduce

```sh
./build.sh
./run.sh
```

Must run on the guest as the unprivileged user (`maxx`, uid 1001). Requires
IPv6 loopback (`::1`) — present by default on the audit guest.

## What the bug is

`udp6_output()` parses per-call IPv6 socket options through `ip6_setpktoptions()`
into a **stack-local** `struct ip6_pktopts opt` (declared at
`udp6_output.c:123`):

```c
struct ip6_pktopts opt, *stickyopt = in6p->in6p_outputopts;
...
if (control) {
    if ((error = ip6_setpktoptions(control, &opt,
        in6p->in6p_outputopts, IPPROTO_UDP, priv)) != 0)
        goto release;                  /* <-- failure: in6p_outputopts
                                          STILL == stickyopt */
    in6p->in6p_outputopts = &opt;      /* <-- only runs on success */
}
...
releaseopt:
    if (control) {
        ip6_clearpktopts(in6p->in6p_outputopts, -1);   /* line 264: BUG */
        in6p->in6p_outputopts = stickyopt;             /* line 265 */
        m_freem(control);
    }
```

On the failure path of `ip6_setpktoptions()` the code jumps to `release`
*before* line 138 reassigns `in6p->in6p_outputopts = &opt`. So when
`releaseopt` runs, `in6p->in6p_outputopts` is **still the user's sticky
options**, not the per-call local. Two consequences:

1. **Sticky-option corruption (CWE-665).**
   `ip6_clearpktopts(in6p->in6p_outputopts, -1)` frees and zeroes the user's
   *persistent* `setsockopt(IPV6_PKTINFO, …)` state (and any other sticky
   IPv6 option). One failed `sendmsg` silently throws away every sticky
   option the user ever set on the socket.

2. **Per-call `M_IP6OPT` leak (CWE-401).**
   `ip6_setpktoptions()` may have already heap-allocated inside the local
   `opt` — via `copypktopts(opt, stickyopt, …)` (line 2961) and/or via
   earlier successfully-parsed cmsgs (e.g. `IPV6_PKTINFO` allocates
   `opt->ip6po_pktinfo`). None of those allocations are freed before `opt`
   goes out of scope. Every failed `sendmsg` leaks ~20 B per
   `struct in6_pkt_info` (plus more for hop-by-hop / dest / route headers).

The reference correct pattern is in `sys/netinet6/raw_ip6.c:301-308,436-443`,
which uses a separate `optp` pointer and only ever clears `optp == &opt`
(never `in6p->in6p_outputopts`). `udp6_output` diverges from that pattern.

## Demonstrators

* **`corrupt.c`** — sets sticky `IPV6_PKTINFO` to `::42`, sends one
  `sendmsg` with a single cmsg whose `cmsg_len == 0` (which makes
  `ip6_setpktoptions()` return `EINVAL` at once), then re-reads
  `IPV6_PKTINFO`. On the buggy kernel the sticky value is **zeroed**;
  on a fixed kernel it is **preserved**.

* **`leak.c`** — drives `sendmsg` in a tight loop with a control buffer
  containing a **valid** `IPV6_PKTINFO` cmsg followed by a malformed
  `cmsg_len == 0` cmsg. Each iteration leaks one `struct in6_pkt_info`
  (20 B). The wrapper `run.sh` snapshots `vmstat -m | grep ip6opt` before
  and after to show the growth.

## Observed impact

| Kernel | corruption test | leak test (4000 iters) |
|---|---|---|
| `#0` unpatched (with-src baseline) | **BUG** — sticky cleared (addr `…002a` → `…0000`) | **+3900** ip6opt, **+~94 KB** M_IP6OPT (cumulative) |
| `#1` patched (single-fix) | **SAFE** — sticky preserved | **0** ip6opt growth (Requests: 3→3.91K, Count: 0) |

Impact classification:
- **Not** a memory-corruption primitive. The "corruption" is of the
  caller's *own* persistent socket state (data destructive to the user's
  own socket), not corruption of kernel data structures or any other
  process's state. There is **no OOB write / UAF / type-confusion** — the
  freed pointers belong to the user's own sticky struct and are nulled
  by `ip6_clearpktopts`, not turned into dangling references.
- **Realistic ceiling:** (a) silent loss of sticky IPv6 options, which
  can have security-relevant consequences (e.g. silently clearing a
  `IPV6_PKTINFO`-pinned source address can change which source address
  subsequent packets use); and (b) kernel memory exhaustion DoS — an
  unprivileged user can drive `M_IP6OPT` growth monotonically with a
  tight `sendmsg` loop, ~20 B/iter with `IPV6_PKTINFO`, more with
  extension-header options.
- **No escalation chain.** Per Phase 6 this is not a write-capable
  primitive, so no `uid=0` chain is attempted. `exploit_chain` = `none`.

## The fix (`fix.diff`)

Change line 264 from

```c
ip6_clearpktopts(in6p->in6p_outputopts, -1);
```

to

```c
ip6_clearpktopts(&opt, -1);
```

i.e. clear the **per-call local** `opt` (which `ip6_setpktoptions()`
always `init_ip6pktopts()`-zeroes first, so it is always safe to clear),
not the user's sticky options. The subsequent
`in6p->in6p_outputopts = stickyopt;` then becomes the no-op restore it
should be on the failure path, and the correct restore on the success
path. This single one-line change closes **both** effects:

* sticky options are no longer touched on the failure path → no
  corruption;
* the local `opt`'s per-call allocations are now properly freed → no leak.

The fix matches the spirit of the `raw_ip6.c` reference pattern (only
clear the local opt, never the sticky) and is minimal/targeted at the
confirmed root cause.

## Files in this pack

| File | Purpose |
|---|---|
| `corrupt.c` | trigger #1 — sticky-option corruption demonstrator |
| `leak.c` | trigger #2 — per-call `M_IP6OPT` leak in a tight loop |
| `build.sh` | exact build: `cc -O2 -Wall -Wextra -o corrupt corrupt.c; cc … -o leak leak.c` |
| `run.sh` | exact run: runs `corrupt`, snapshots `vmstat -m`, runs `leak`, snapshots again |
| `build.log` | full untrimmed build output on the guest |
| `run.log` | full untrimmed run output on the **unpatched `#0`** kernel (bug reproduced) |
| `run.2.log`, `run.3.log` | extra corruption-test runs on the unpatched kernel (determinism) |
| `fix.diff` | the validated one-line `git apply`-able fix |
| `fix_build.log` | full single-fix kernel build log (`make -j6 nativekernel`) |
| `fix_run.log` | full run output on the **patched `#1`** kernel (bug gone) |
| `fix_run.2.log`, `fix_run.3.log` | extra corruption-test runs on the patched kernel (determinism) |
| `env.txt` | guest `uname`, `cc` version, baseline `vmstat -m ip6opt` |
| `VERDICT.md` | this file |
| `manifest.json` | machine-readable catalog |
