# DF-0585 — VERDICT

## Verdict: **REPRODUCED** (local DoS — interface wedge, reboot required) → **FIX VALIDATED** on single-fix kernel

## The bug, confirmed in source

`tapioctl()` in `sys/net/tap/if_tap.c`:

```
726: static int
727: tapioctl(struct dev_ioctl_args *ap)
728: {
...
738:     ifnet_serialize_all(ifp);      <-- acquire interface serializer
739:     error = 0;
740:
741:     switch (ap->a_cmd) {
742:     case TAPSIFINFO:
743:         tapp = (struct tapinfo *)data;
744:         if (ifp->if_type != tapp->type)
745:             return (EPROTOTYPE);    <-- BUG: early return, lock NOT released
746:         ifp->if_mtu = tapp->mtu;
...
830:     }
831:
832:     ifnet_deserialize_all(ifp);    <-- the ONLY release point
833:     return (error);
```

The serializer acquired at **line 738** is released at exactly one place,
**line 832**. The `TAPSIFINFO` early-return at **line 745** bypasses it,
orphaning the lock for the lifetime of the interface. The same serializer is
re-acquired in `tapclose()` at **line 426**, so once the orphan exists the
close of the fd — and every other op that serializes the interface — blocks
forever.

## Trigger & proof (re-confirmed on this run, unpatched #0)

1. `open("/dev/tap", O_RDWR)` → clone creates `tap0`.
2. `ioctl(fd, TAPSIFINFO, &ti)` with `ti.type = 0xFF` (≠ `IFT_ETHER`=6) →
   hits the early return at line 745 → serializer orphaned.
3. Fork; parent drops its fd reference; the child's `close()` is the final
   reference → `tapclose()` → `ifnet_serialize_all()` at line 426 → **blocks
   forever**.

Decisive kernel-side evidence (unpatched `#0` + original `if_tap.ko`):

```
[+] child pid 858 still running 3s into its close() call
[+] -> DF-0585 REPRODUCED: interface permanently wedged

  PID STAT  WCHAN  COMM
  858 D1    slize  leak_tap_lock      <- child wedged in uninterruptible sleep
                                         on the orphaned ifnet serializer
```

An independent detached `ifconfig tap0` (no shared fd) **also wedges** in
D-sleep on `wchan=slize` — proving the *entire interface* is orphaned, not
just the triggering fd. Recovery requires a reboot.

## Privilege / reachability (verified)

- `/dev/tap` clone node is `0600 root:wheel` (`if_tap.c:183`); `tapopen()`
  (`if_tap.c:323`) requires `caps_priv_check(SYSCAP_RESTRICTEDROOT)` unless
  `net.link.tap.user_open=1`, and even then the devfs node stays `0600`.
- Unprivileged `maxx` (uid 1001, not in wheel) gets `EACCES` — confirmed.
- **Net: root/wheel-reachable local DoS.** Realistic threat model: a
  privileged network-config helper (VPN/bridge/jail setup, a qemu/bhyve VM
  process given a tap fd) that is buggy or compromised can wedge the kernel.

## Impact

- **Class:** CWE-667 (lock orphan) → permanent local denial of service.
- **Effect:** the tap(4) interface becomes unusable; the open fd cannot be
  closed; `ifconfig`, RX/TX, module-unload all block forever. Reboot required.
- **No panic / no memory corruption** → no exploit chain beyond DoS.
  Impact is `dos`.

## PoC changes from the seeded version

1. The seeded `leak_tap_lock.c` had a **wrong `struct tapinfo`** (16-byte
   layout). The real `struct tapinfo` (`sys/net/tap/if_tap.h:46`) is **8 bytes**
   (`{int baudrate; short mtu; u_char type; u_char dummy;}`). Because
   `TAPSIFINFO` is `_IOW('t', 91, struct tapinfo)`, the ioctl *number* is
   derived from `sizeof(struct tapinfo)`; the wrong struct produced the wrong
   ioctl number → kernel `switch` fell through to `default: ENOTTY`, never
   reaching the buggy path. Fixed by vendoring the correct 8-byte struct.
2. Reworked the proof into a fork-dance (parent drops its ref first so the
   child's `close()` is the final close that runs `tapclose`), so the harness
   never hangs on the wedge.
3. **This run:** fixed the wedge *detection*. The original `kill(pid, 0)` check
   cannot distinguish a genuinely-wedged child (D-sleep) from a child that
   exited and is now a **zombie** — `kill(pid,0)` succeeds for both, so the
   PoC falsely printed PROOF on the FIXED kernel. Replaced it with
   `waitpid(pid, &st, WNOHANG)`: a reaped child ⇒ close() returned ⇒ serializer
   released ⇒ FIXED; a still-running child after the probe window ⇒ wedged ⇒
   BUG. The PoC now prints honest, opposite verdicts on the two kernels.

## Recommended fix — VALIDATED on a built+booted single-fix kernel

Release the serializer on the early-return path. One-line change at
`if_tap.c:744-747` (see `fix.diff`):

```c
    if (ifp->if_type != tapp->type) {
        ifnet_deserialize_all(ifp);
        return (EPROTOTYPE);
    }
```

### Fix-validation result: **FIXED** (clean before/after)

**Critical build nuance discovered:** in `X86_64_GENERIC`, tap(4) is a **KLD
module**, not a static kernel device. The bug and the fix live in `if_tap.ko`,
*not* in the kernel binary. The first fix-validation rebuilt the kernel but
left the original Jun-29 `if_tap.ko` on disk; `kldload if_tap` then loaded the
*unpatched* module and the wedge reproduced even on the `#1` kernel. The
correct validation installs **both** the rebuilt kernel AND the rebuilt
`if_tap.ko`:

- `/boot/kernel/if_tap.ko` ← patched module (sha256 `bfe90971…`, 263008 B)
- `/boot/kernel/if_tap.ko.orig` ← baseline module (sha256 `8e1a6da3…`, 262320 B)

| Probe                         | Baseline `#0` + orig `if_tap.ko`      | Patched `#1` + new `if_tap.ko`     |
|-------------------------------|----------------------------------------|------------------------------------|
| child state 3s into close()   | `D1` wchan=`slize` (WEDGED)            | `Z` (EXITED)                       |
| independent `ifconfig tap0`   | hangs >120s in D-sleep (`slize`)       | returns 0s, rc=1 (clean teardown)  |
| PoC verdict                   | REPRODUCED (wedge)                     | NOT reproduced (no wedge)          |

Built kernel: `DragonFly 6.5-DEVELOPMENT #1: Thu Jul  2 16:46:56 UTC 2026`
(`make -j6 nativekernel KERNCONF=X86_64_GENERIC`, rc=0; full log `fix_build.log`).
Confirmed deterministic across 3 runs on the patched kernel. The fix supersedes
the finding markdown's `error=…; break;` proposal (functionally equivalent —
both route through the single deserialize — but the applied `ifnet_deserialize_all
+ return` is more localized and matches the immediate-acquire/immediate-release
style of the surrounding error paths).
