# DF-0630 — VERDICT

**Status: REPRODUCED** (and fix VALIDATED on a single-fix kernel).

| | |
|---|---|
| Finding | `udp6_ctlinput` returns without `lwkt_replymsg`, deadlocking the netisr on a single crafted ICMPv6 packet |
| Class | Remote unauthenticated one-shot network-stack DoS (netmsg reply-contract violation) |
| Severity | High (CVSS 3.1 AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H) |
| Guest | DragonFly 6.5-DEVELOPMENT unpatched `#0` → reproduced; single-fix `#1` → gone |
| Impact | `dos` — permanent netisr wedge, box must be rebooted |

## Verdict (one line)

The bug is real and exploitable as described: a single ~48-byte crafted ICMPv6
Destination Unreachable permanently wedges a DragonFly netisr CPU, and the
one-line fix (`return;` → `goto out;` at `udp6_usrreq.c:435`) deterministically
closes it.

## Mechanism (every hop cited)

1. **Trigger packet.** An ICMPv6 Destination Unreachable (type 1) whose quoted
   inner IPv6 packet has `nh=UDP(17)` and `plen=0` (zero UDP bytes). With the
   outer IPv6 header this is 88 bytes on the wire.
2. **Dispatch into udp6_ctlinput.** On input the kernel runs
   `icmp6_input` → `icmp6_notify_error` (`sys/netinet6/icmp6.c:1030-1046`),
   which derives `nxt` from the *quoted* inner header (`icmp6.c:874`, no prior
   UDP traffic or credentials required), sets `ip6cp.ip6c_off = eoff` where
   `eoff = off + 8 + 40 = 88` (`icmp6.c:875,1033`), and calls
   `so_pr_ctlinput(&inet6sw[ip6_protox[IPPROTO_UDP]], …)` (`icmp6.c:1044`).
3. **Synchronous netmsg.** `so_pr_ctlinput` (`sys/kern/uipc_msg.c:594-611`)
   sets the reply port to the caller's own netisr port
   (`netmsg_init(..., &curthread->td_msgport, ...)`, `uipc_msg.c:604`) and
   dispatches via `lwkt_domsg(port, …)` (`uipc_msg.c:610`). udp6's
   `pr_ctlport` is `cpu0_ctlport` (`sys/netinet6/in6_proto.c:144`;
   `sys/net/netisr.c:648-654`), so the message always targets cpu0's netisr.
4. **`lwkt_domsg` blocks the caller.** `lwkt_domsg` sets `MSGF_SYNC` and, on
   `EASYNC` from the target port, calls `lwkt_waitmsg`
   (`sys/kern/lwkt_msgport.c:184-203`). The netisr thread port's
   `mp_putport` = `lwkt_thread_putport`, which **always returns `EASYNC`**
   — even when the target is the current CPU (`lwkt_msgport.c:712-738`) — and
   `lwkt_thread_waitmsg` then sleeps in `lwkt_sleep("waitmsg", …)` until
   `MSGF_DONE` (`lwkt_msgport.c:768-798`).
5. **The bug.** `udp6_ctlinput` (`sys/netinet6/udp6_usrreq.c:384`) tests, at
   `:434-435`:
   ```c
   if (m->m_pkthdr.len < off + sizeof(*uhp))   /* 88 < 88+4 -> TRUE */
       return;                                 /* <-- BUG */
   ```
   `sizeof(*uhp)` is 4 (two `u_int16_t` ports, `udp6_usrreq.c:396-399`). The
   bare `return;` skips the `out:` label (`:448`) and the mandatory
   `lwkt_replymsg(&msg->ctlinput.base.lmsg, 0)` (`:449`). Every other early
   exit in the function correctly uses `goto out;` (`:403, :406, :412`); **this
   one line is the sole exception**.
6. **Deadlock.** With `lwkt_replymsg` never called, `MSGF_DONE` is never set,
   so the caller netisr sleeps in `lwkt_waitmsg` forever. Because the target
   is always cpu0 (`cpu0_ctlport`), a packet whose `icmp6_input` runs on cpu0
   self-deadlocks cpu0; a packet that runs on cpuN (N≠0) wedges cpuN while
   cpu0 runs the handler and returns without replying. One packet wedges one
   netisr permanently; a handful (one per CPU, trivially arranged by varying
   the source so RSS spreads them) freezes all network processing until reboot.

## Reproduction (unpatched `#0`)

The PoC injects from *inside* the guest because the QEMU user-mode network is
IPv4-only NAT (no host→guest IPv6). A root raw `IPPROTO_ICMPV6` socket sends
the 48-byte payload to `::1`; `rip6_output` auto-computes the ICMPv6 checksum
(`sys/netinet6/raw_ip6.c:392-419`) and the packet loops back through `lo0`
into `icmp6_input` on cpu0 → self-deadlock. The root requirement is purely an
artifact of in-guest injection; the live vuln is remotely unauthenticated.

| Step | Result |
|---|---|
| baseline `ping6 -c1 ::1` | 0.076 ms, 0% loss |
| baseline ssh round-trip | 0.22 s |
| `trigger ::1 1` | sent 48 bytes, rc 0 |
| `ping6 ::1` after trigger | **HANG** (8 s timeout, rc 124) |
| ssh round-trip after trigger | **HANG** (8 s timeout, rc 124) |
| `vm.sh status` after trigger | **down** (network stack dead) |
| serial `boot.log` | login prompt still up — **NO panic, NO `fatal trap`, NO `ddb>`** |

I.e. a single packet froze the entire guest; the kernel did not crash (it is a
permanent netisr wedge, exactly as claimed). The box required `vm.sh reset` to
recover. Full untrimmed log: `run.log`.

## Exploit chain

Remote network DoS — no memory-corruption primitive, so there is no escalation
chain to develop. The realistic ceiling is: **one unauthenticated IPv6 packet
permanently wedges one netisr CPU; ~6 packets (one per CPU) freeze the whole
network stack**, killing existing sessions and blocking all new ones until
reboot. The original `udp6_ctlinput_deadlock.py` (scapy) trigger is retained
for external attackers; the in-guest C `trigger` is added for this QEMU setup.

## Fix (authored, validated)

`fix.diff` — one line, minimal, targets the confirmed root cause:

```diff
--- a/sys/netinet6/udp6_usrreq.c
+++ b/sys/netinet6/udp6_usrreq.c
@@ -432,7 +432,7 @@
 
 		/* check if we can safely examine src and dst ports */
 		if (m->m_pkthdr.len < off + sizeof(*uhp))
-			return;
+			goto out;
```

This routes the early exit through the existing `out:` label so the mandatory
`lwkt_replymsg(&msg->ctlinput.base.lmsg, 0)` at `:449` is always executed,
restoring the netmsg reply contract. **This matches the finding markdown's
`## Recommended fix` proposal exactly** (same one-liner, same rationale).

`git apply --check` passes against the read-only `sys/` tree.

## Fix validation (Phase 8)

Built a single-fix kernel on the `with-src` base (warm obj, `.c`-only change →
incremental build, ~6 min, rc 0, no errors — `fix_build.log`):

- unpatched `#0` `kern.version`: `DragonFly 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026`
- patched `#1` `kern.version`: `DragonFly 6.5-DEVELOPMENT #1: Thu Jul 2 19:52:53 UTC 2026`
- patched `sha256(/boot/kernel/kernel)`: `1dfc3c91…b2ed`
- Installed as the bare `/boot/kernel/kernel` (the name the DragonFly loader
  boots), confirmed the `#0 → #1` bump, then rebooted.

Re-ran the SAME trigger on the patched kernel:

| Probe on `#1` | Result |
|---|---|
| baseline `ping6 ::1` | 0.077 ms, 0% loss |
| after single-shot trigger ×1 (×5 runs) | `ping6 ::1` 0.08–0.20 ms, 0% loss; ssh `SSH_OK`; vm **up** |
| after 12-shot burst | `ping6 ::1` 0.20/1.46 ms, 0% loss; vm **up** |

**Before/after contrast:** on `#0` one trigger → ssh rc 124 (8 s timeout), vm
**down**; on `#1` five single-shots + a 12-shot burst → every follow-up ping6
< 2 ms / 0% loss, ssh responsive, vm **up**. The fix is deterministic and
closes the bug. Full log: `fix_run.log`.

## PoC changes

- Added `trigger.c` — in-guest C injector (raw `IPPROTO_ICMPV6` socket) sending
  the crafted ICMPv6 DestUnreach to `::1`; needed because the QEMU net is
  IPv4-only NAT so the scapy script (which assumes host→guest IPv6) cannot run
  here. Documents why root is needed in-guest vs. the remote-unauthenticated
  threat model.
- Added `build.sh` / `run.sh` repro scripts.
- Authored `fix.diff` (above).
- Kept the original `udp6_ctlinput_deadlock.py` for external-attacker use.

## Notes / defense-in-depth

- Sibling handlers `rip6_ctlinput` (`sys/netinet6/raw_ip6.c:229`) and
  `tcp6_ctlinput` (`sys/netinet6/tcp_subr.c`) share this netmsg-conversion
  heritage and use the same `cpu0_ctlport`; they were called out in the
  finding for the same `return`-without-reply pattern and are worth a separate
  audit pass.
- Recovered the guest (`vm.sh reset with-src`) at the end of Phase 8.
