# DF-0588 — VERDICT

## Verdict: **REPRODUCED** (privileged local resource-exhaustion DoS, S:C)

## The bug, confirmed in source

`tunwrite()` in `sys/net/tun/if_tun.c` assembles an mbuf chain whose head is
`top`. The loop at lines 875-882 only allocates the *next* mbuf (line 881) when
more data remains, so on the final iteration — when `uio->uio_resid` hits 0 —
the `MGET` is **not taken** and the local `m` is left pointing at the **last
(tail) mbuf** of the chain, not the head:

```c
870:  MGETHDR(m, M_WAITOK, MT_DATA);          /* m = chain head initially */
874:  mp = &top;
875:  while (error == 0 && uio->uio_resid > 0) {
...
878:      *mp = m;                              /* append m to chain */
879:      mp = &m->m_next;
880:      if (uio->uio_resid > 0)
881:          MGET(m, M_WAITOK, MT_DATA);       /* NOT taken on last iter */
882:  }
```

After the loop: `top` = chain head; `m` = chain tail (`m->m_next == NULL`).

When `TUN_IFHEAD` is set and the user 4-byte family is not `AF_INET`/`AF_INET6`,
the default case frees the wrong variable:

```c
952:  default:
953:      m_freem(m);              /* BUG: frees only the tail (1 mbuf) */
954:      return (EAFNOSUPPORT);
955:  }
```

`m_freem(m)` walks `m->m_next`, which is NULL at the tail, so it frees exactly
one mbuf. The chain head `top` and every intermediate mbuf are leaked. `top` is
a local that goes out of scope on return, so the leak is **permanent**.

## Trigger & proof

1. `open("/dev/tun", O_RDWR)` → clone creates a tun interface.
2. `ioctl(fd, TUNSIFHEAD, &one)` — enable IFHEAD.
3. `write(fd, buf, 65539)` with `buf[0..3] = 0xffffffff` (family ≠ AF_INET/6) →
   `EAFNOSUPPORT`, ~263 mbufs leaked.
4. Loop; watch `netstat -m`.

Measured (mbufs-in-use, `netstat -m`):

| run           | writes | before  | after   | Δ       | mbufs/write |
|---------------|--------|---------|---------|---------|-------------|
| run #1        | 50     | 4       | 13203   | 13199   | ~264        |
| run #2        | 100    | 13204   | 39603   | 26399   | ~264        |
| stress        | ~137   | 39603   | 76247   | 36644   | ~267        |

Run #2 starting at 13204 (the leftover from run #1, **not reclaimed**) and
climbing by the same ~264/write proves the leak is **monotonic and unreclaimed**
— the kernel never frees the leaked chain heads.

Stress to exhaustion:

```
76247/72904 mbufs in use (current/max):     <- over the nominal max
12 requests for memory denied
boot.log: Warning: objcache(mbuf) exhausted on cpu1!
```

The `objcache(mbuf) exhausted` kernel warning + `requests for memory denied`
confirm system-wide mbuf exhaustion: a privileged local user can drain the
global mbuf zone and degrade networking for every tenant — the **S:C** impact
in the CVSS vector.

## Impact

- **Class:** CWE-401 (resource leak / missing release) → resource-exhaustion DoS.
- **Effect:** each unsupported-family write leaks ~263 mbufs; ~280 such writes
  exhaust a default `nmbclusters=16912` / 72904-mbuf system, after which new
  mbuf allocations are denied system-wide (`objcache(mbuf) exhausted`). The
  triggering `write()` itself eventually blocks in `M_WAITOK` once the zone is
  empty. Recovery requires a reboot.
- **Privilege:** host-root only. `tunopen()` (`if_tun.c:283`) requires
  `caps_priv_check(SYSCAP_RESTRICTEDROOT)` with no escape; `/dev/tun` is
  `0600 uucp:dialer`. Matches CVSS `PR:H`. (Unprivileged `maxx` gets `EACCES`.)
- **No memory corruption** (no overflow/UAF) — no exploit chain beyond DoS.
  Impact is `dos` (system-scoped resource exhaustion).

## PoC changes from the seeded version

The seeded `tun_leak.c` had two bugs that prevented it from working:
1. It opened `/dev/tun3`, which does not exist (tun uses the `/dev/tun` clone
   device). Fixed to open `/dev/tun`.
2. It `#include <net/if_tun.h>`, which is **not shipped** in the guest's
   `/usr/include`. Vendored `TUNSIFINFO` (`_IOW('t', 96, int)`) and `TUNMRU`
   (`65535`) directly.

Also added a configurable write count (default 50 for a safe demo), an
`ENOBUFS`/`ENOMEM` detection branch for the stress-to-exhaustion case, and a
`run.sh` that measures `netstat -m` before/after to quantify the leak per write.

## Recommended fix

Free the chain **head** `top`, not the tail `m`. One line at `if_tun.c:953`:

```c
    default:
        m_freem(top);          /* was: m_freem(m) */
        return (EAFNOSUPPORT);
```

This frees the entire assembled chain. See `fix.diff` (`git apply --check`
passes). After the fix, the same 50/100/200-write workload keeps the mbuf count
flat.

---

## PHASE 8 — Fix validation on a single-fix kernel (verified)

The `m_freem(m)` → `m_freem(top)` fix was validated end-to-end by building a
**single-fix kernel** from the audited `/usr/src` tree (warm obj), installing
it, rebooting, and re-running the **identical** PoC workload.

### Setup

- **Base guest:** DragonFly 6.5-DEVELOPMENT **`#0`** (unpatched audit-source
  kernel, `with-src` snapshot — full `/usr/src` + warm
  `/usr/obj/usr/src/sys/X86_64_GENERIC`).
- **Patch applied:** ONLY `findings/poc/DF-0588/fix.diff` via
  `cd /usr/src && patch -p1 --forward < /root/fix.diff` →
  `Hunk #1 succeeded at 950.` (verified: `sed -n 952,954p` shows `m_freem(top)`).
- **Build:** `make -j6 nativekernel KERNCONF=X86_64_GENERIC` →
  `rc=0`, `>>> Kernel build for X86_64_GENERIC completed`. Only `if_tun.c`
  recompiled (warm obj); full build log in `fix_build.log`.
- **Install:** copied `kernel.stripped` → `/boot/kernel/kernel` (the file the
  loader actually boots — `/boot/kernel/kernel.stripped` alone is *not* loaded),
  plus `kernel.debug`. (First boot attempt used only `.stripped/.debug` and
  silently kept the old `#0`; the loader boots the bare name `kernel`.)
- **Booted kernel:** `DragonFly 6.5-DEVELOPMENT #1: Thu Jul  2 06:56:05 UTC 2026`
  (build timestamp matches the rebuilt `kernel.debug`).

### Before (unpatched `#0`) — `baseline_repro.log`

| run      | writes | before | after  | Δ       | mbufs/write |
|----------|--------|--------|--------|---------|-------------|
| A        | 50     | 7      | 13207  | 13200   | **264**     |
| B (cont) | 100    | 13207  | 39607  | 26400   | **264**     |

Leak is monotonic and unreclaimed (run B starts from run A's terminal count).

### After (single-fix `#1`) — `fix_run.log`

| run | writes | before | after | Δ   | mbufs/write |
|-----|--------|--------|-------|-----|-------------|
| A   | 50     | 7      | 7     | **0** | 0         |
| B   | 50     | 7      | 7     | **0** | 0         |
| C   | 200    | 7      | 7     | **0** | 0         |

**Mbuf count stays perfectly flat across 300 total writes.** The leak is gone.

### Classification

- **`fix_status = fixed`** — baseline climbed at 264 mbufs/write; patched kernel
  leaks 0 across 50/50/200-write runs (identical workload, identical PoC binary).
- **`fix_kernel_uname`** — `DragonFly 6.5-DEVELOPMENT #1: Thu Jul  2 06:56:05 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64`.
- The fix is the **exact one-line change** in the finding markdown's
  `## Recommended fix`; the runner's `fix.diff` matches the finding proposal
  (no supersede needed).
- No regressions observed: patched kernel boots clean, networking normal, no
  `boot.log` warnings or panics during the test workload.

### Notes for the build-install-reboot workflow (template for future findings)

1. The loader boots `/boot/kernel/kernel`, NOT `kernel.stripped`. A single-fix
   install must overwrite `/boot/kernel/kernel` (copy `kernel.stripped` there).
2. `kern.version` is the authoritative "did the new kernel boot" check — the `#N`
   suffix and build timestamp change with each link.
3. Warm-obj `nativekernel` for a `.c`-only fix is fast (~3-4 min wall here despite
   a full module sweep); background it (`&`) and poll `kill -0 $(cat nk.pid)` +
   `grep NK_DONE`, because the backgrounded child holds the ssh channel open.
