β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-0630

udp6_ctlinput returns without lwkt_replymsg, deadlocking the netisr on a single crafted ICMPv6 packet

Field Value
ID DF-0630
Status new
Severity High
CVSS 3.1 CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
CWE CWE-400 Uncontrolled Resource Consumption (netmsg reply-contract violation β†’ permanent thread wedge)
File sys/netinet6/udp6_usrreq.c
Lines 434-435 (bug); 448-449 (skipped reply)
Area netinet6 (UDPv6 ICMPv6-error control input)
Confidence certain
Discovered 2026-07-02
Reported pending

Summary

When an ICMPv6 error quotes a UDP/IPv6 packet whose quoted UDP header is too short to contain the two port fields, udp6_ctlinput takes an early return; (line 435) that bypasses the out: label and the mandatory lwkt_replymsg(&msg->ctlinput.base.lmsg, 0) at line 449. Because udp6's pr_ctlport is cpu0_ctlport (in6_proto.c:144) and so_pr_ctlinput dispatches the message synchronously via lwkt_domsg with the reply port set to the caller's own netisr port (uipc_msg.c:604-610; lwkt_domsg blocks in lwkt_waitmsg until MSGF_REPLY per lwkt_msgport.c:185-203), the netisr thread that received the ICMPv6 packet blocks forever. A single ~88-byte unauthenticated ICMPv6 Destination Unreachable thus permanently wedges a netisr CPU; a handful of such packets across CPUs freezes the whole network stack until reboot.

Root cause

udp6_ctlinput(netmsg_t msg) (udp6_usrreq.c:384) is a DragonFly netmsg handler. Every such handler must call lwkt_replymsg before returning, because icmp6_notify_error (icmp6.c:1044) reaches it via so_pr_ctlinput (uipc_msg.c:594-611) which does:

netmsg_init(&msg.base, NULL, &curthread->td_msgport, 0, pr->pr_ctlinput);
...
lwkt_domsg(port, &msg.base.lmsg, 0);

lwkt_domsg sets MSGF_SYNC and, on EASYNC from the netisr spinport, calls lwkt_waitmsg which sleeps until the reply arrives (lwkt_msgport.c:193-198).

Inside udp6_ctlinput, with ip6 != NULL (any ICMPv6 error that quotes an invoking packet, icmp6.c:1030-1036), the function executes:

434:        if (m->m_pkthdr.len < off + sizeof(*uhp))
435:            return;                    /* <-- BUG: skips lwkt_replymsg */

sizeof(*uhp) is 4 (two u_int16_t ports, lines 396-399). If the attacker truncates the ICMPv6 packet so fewer than 4 bytes of the quoted UDP header are present, control hits return; at line 435, skipping out: (line 448) and lwkt_replymsg(&msg->ctlinput.base.lmsg, 0) (line 449). The synchronous caller in so_pr_ctlinput therefore never observes MSGF_DONE and sleeps in lwkt_waitmsg indefinitely.

Every other early exit in the function correctly uses goto out; (lines 403, 406, 412). This one line is the sole exception.

Threat model & preconditions

  • Attacker position: any host that can deliver an IPv6 packet to a victim interface IPv6 address (global or, for on-link attackers, link-local). IPv6 is enabled by default in the DragonFly kernel and ICMPv6 cannot be broadly blocked without breaking NDP/IPv6, so this is effectively default-config remote.
  • No credentials, no prior UDP traffic required β€” the ICMPv6 error is processed purely on its quoted content (icmp6.c:874 walks the quoted packet's next-header chain to derive nxt, then dispatches inet6sw[ip6_protox[nxt]]; the attacker simply sets the quoted inner IPv6 header's ip6_nxt = 17/UDP).
  • Trigger: send a single crafted ICMPv6 Destination Unreachable whose quoted invoking packet is an IPv6 header with nh=UDP and zero bytes of UDP payload (or fewer than 4 bytes), so the quote ends before the 4-byte port pair. m->m_pkthdr.len will be < off + 4.
  • Impact: permanent denial of service. One packet wedges one netisr CPU; a few packets (one per CPU) freeze all network processing on the box, killing existing sessions and preventing any new ones, recoverable only by reboot.

Proof of concept

PoC source: findings/poc/DF-0630/udp6_ctlinput_deadlock.py.

Build & run

pip install scapy   # or: pkg install py311-scapy on DragonFlyBSD
python3 udp6_ctlinput_deadlock.py <victim_v6>

Expected output

Immediately after sending, the victim's IPv6 (and typically all) network traffic stalls: an in-progress ssh -6 session freezes, ping6 victim stops receiving echo replies, and top/ps auxww shows a netisr thread pinned in lwkt_waitmsg. The box does not self-recover and must be rebooted. ddb> ps shows the blocked netisr thread with a udp6_ctlinput/lwkt_waitmsg backtrace.

Change the early return; to goto out; so control falls through to the existing lwkt_replymsg at the out: label:

--- a/sys/netinet6/udp6_usrreq.c
+++ b/sys/netinet6/udp6_usrreq.c
@@ -431,7 +431,7 @@ udp6_ctlinput(netmsg_t msg)
     */

    /* check if we can safely examine src and dst ports */
    if (m->m_pkthdr.len < off + sizeof(*uhp))
-       return;
+       goto out;

    bzero(&uh, sizeof(uh));
    m_copydata(m, off, sizeof(*uhp), &uh);

Defense-in-depth note: the sibling handlers rip6_ctlinput (raw_ip6.c:229) and tcp6_ctlinput (tcp_subr.c:1549) should be audited for the same return-without-reply pattern, since they share this netmsg-conversion heritage from KAME/FreeBSD where the originals were plain void functions.

References

Timeline

  • 2026-07-02 Discovered during automated DragonFlyBSD kernel security audit.
  • 2026-07-02 Reported to DragonFlyBSD security contact (pending).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0630 Β· 13 files
FileTypeDescriptionSize
trigger.c trigger-source in-guest raw ICMPv6 socket injector; sends crafted DestUnreach (inner IPv6 nh=UDP plen=0) to ::1 3.7 KB view raw
udp6_ctlinput_deadlock.py trigger-source original scapy trigger for external attackers (host->guest IPv6) 1.5 KB view raw
build.sh build-script cc -o trigger trigger.c 203 B view raw
run.sh run-script trigger + timed ping6/ssh probes 1.2 KB view raw
fix.diff suggested-fix one-liner: udp6_usrreq.c:435 return; -> goto out; (git apply-able) 357 B view raw
run.log run-log UNPATCHED #0 decisive run: baseline + trigger -> ping6/ssh hang (rc124), vm down, no panic 1.7 KB view raw
fix_run.log run-log PATCHED #1 runs: 5 single-shots + 12-shot burst, ping6 <2ms/0% loss, ssh OK, vm up 1.9 KB view raw
fix_build.log build-log full nativekernel output for the single-fix build, rc=0 5.6 MB ↓ download
env.txt environment uname #1, cc 8.3, ncpu=6, ipv6 addrs, patched line 435 410 B view raw
VERDICT.md verdict full narrative: mechanism, repro, fix, validation 7.7 KB ↓ raw
README.md readme PoC overview and run instructions 3.0 KB ↓ raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme PoC overview and run instructions
↓ download raw

DF-0630 β€” PoC: udp6_ctlinput netisr deadlock

Remote unauthenticated single-packet permanent netisr wedge via crafted ICMPv6 Destination Unreachable whose quoted UDP header is too short.

Status: REPRODUCED on unpatched #0; fix VALIDATED on single-fix #1. See VERDICT.md for the full analysis.

Files

  • trigger.c β€” verified in-guest injector (raw IPPROTO_ICMPV6 socket, root). Sends the crafted ICMPv6 DestUnreach (inner IPv6 nh=UDP, plen=0) to ::1; the guest's own icmp6_input dispatches udp6_ctlinput, which return;s at udp6_usrreq.c:435 without lwkt_replymsg, wedging the netisr that ran icmp6 input (self-deadlock on cpu0 via cpu0_ctlport).
  • udp6_ctlinput_deadlock.py β€” original scapy trigger, for an external attacker with IPv6 reachability to the victim (the QEMU user-mode net is IPv4-only NAT, so the C injector is used in-guest instead).
  • build.sh / run.sh β€” exact repro scripts.
  • fix.diff β€” the one-line verified fix (return; β†’ goto out;).
  • run.log / fix_run.log / fix_build.log / env.txt β€” full evidence.

Build & run (in-guest, as root)

./build.sh                       # cc -o trigger trigger.c
./run.sh                         # trigger ::1 1 ; then timed ping6/ssh probes
./run.sh 12                      # 12-shot burst

run.sh needs root (raw ICMPv6 socket). The root requirement is an artifact of in-guest injection; the live vulnerability is remotely triggerable by any unauthenticated host that can deliver an IPv6 packet to the victim.

Expected outcome

Unpatched kernel (6.5-DEVELOPMENT #0) β€” BUG PRESENT

Immediately after sending one packet: - ping6 ::1 hangs forever (8 s timeout, rc 124). - ssh round-trip hangs (rc 124); vm.sh status β‡’ down. - serial console still shows the live login prompt β€” no panic, no fatal trap, no ddb> (it is a permanent netisr wedge, not a crash). - The box must be rebooted (vm.sh reset) to recover.

Patched kernel (6.5-DEVELOPMENT #1, fix applied) β€” BUG GONE

After the same trigger (single-shot Γ—5 and a 12-shot burst): - ping6 ::1 replies in < 2 ms, 0% loss. - ssh responsive; vm.sh status β‡’ up.

Attack vector

  • Remote unauthenticated β€” any host that can deliver an IPv6 packet to a victim address (global, or link-local for on-link attackers).
  • IPv6 enabled by default; ICMPv6 cannot be broadly blocked without breaking NDP/IPv6.
  • No prior UDP traffic and no credentials required β€” icmp6_notify_error derives the upper-layer protocol purely from the quoted inner header (icmp6.c:874).
  • One packet wedges one netisr CPU permanently; ~N packets (one per CPU, varied source so RSS spreads them) freeze the entire network stack.

Fix

One line at sys/netinet6/udp6_usrreq.c:435:

-           return;
+           goto out;

Routes the early exit through the existing out: label so the mandatory lwkt_replymsg(&msg->ctlinput.base.lmsg, 0) at :449 always runs. Matches the finding markdown's recommended fix exactly.

VERDICT.md verdict full narrative: mechanism, repro, fix, validation
↓ download raw

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:

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

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED the fix. The SAME trigger that deadlocked the unpatched #0 baseline (ping6 ::1 / ssh hang at rc=124, vm down, no panic) does NOT wedge the single-fix #1 kernel: across 5 single-shot triggers and a 12-shot burst, ping6 ::1 stayed <2 ms / 0% loss, ssh stayed responsive, vm stayed up => the one-liner (return; -> goto out;) deterministically closes the netisr deadlock by guaranteeing lwkt_replymsg on every return.

baseline #0: trigger ::1 1 -> ping6 ::1 HANG rc=124 ; ssh HANG rc=124 ; vm.sh status=down (login prompt still up, no panic). patched #1: trigger ::1 1 x5 + ::1 12 burst -> ping6 ::1 0.08-1.46 ms / 0% loss ; ssh SSH_OK rc=0 ; vm.sh status=up. nativekernel build rc=0 (no errors).
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Thu Jul 2 19:52:53 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC (sha256 /boot/kernel/kernel = 1dfc3c919e6589a78b6afc7d6031d00a3abfbb04aceb6a1b6065126a8eb1b2ed)

Confirmed kernel references

Detail

Exploit chain

Remote unauthenticated one-shot network-stack DoS (netmsg reply-contract violation), not a memory-corruption primitive, so there is no escalation chain to develop. Ceiling: one ~48-byte ICMPv6 packet wedges one netisr CPU permanently; ~6 packets (one per CPU, source-varied so RSS spreads them) freeze the whole network stack, killing existing sessions and blocking all new ones until reboot. Original udp6_ctlinput_deadlock.py (scapy) retained for external attackers; in-guest C trigger.c added because the QEMU user-mode net is IPv4-only NAT.

Evidence (decisive lines)

UNPATCHED #0: baseline ping6 ::1 = 0.076 ms, ssh = 0.22 s; after `trigger ::1 1`: `ping6 ::1` -> HANG (timeout rc=124), ssh round-trip -> HANG (rc=124), vm.sh status -> down; boot.log shows login prompt still up (no panic). PATCHED #1: after 5 single-shot triggers + a 12-shot burst, ping6 ::1 = 0.08-1.46 ms / 0% loss, ssh SSH_OK, vm up every time.

PoC changes

Added trigger.c (in-guest raw IPPROTO_ICMPV6 socket injector sending the crafted DestUnreach to ::1 β€” needed because the QEMU net is IPv4-only NAT so the scapy host->guest script cannot run here; rip6_output auto-computes the ICMPv6 checksum at raw_ip6.c:418); added build.sh/run.sh repro scripts; authored fix.diff (one-liner); kept the original udp6_ctlinput_deadlock.py for external attackers. Full evidence pack in findings/poc/DF-0630/ (VERDICT.md, run.log, fix_run.log, fix_build.log, env.txt, manifest.json).

Verified recommended fix

One-line change at sys/netinet6/udp6_usrreq.c:435: return; -> goto out;, so the early exit routes through the existing out: label and the mandatory lwkt_replymsg(&msg->ctlinput.base.lmsg,0) at :449 always executes, restoring the netmsg reply contract. git apply --check passes; matches the finding markdown's ## Recommended fix proposal exactly. Full git-apply-able diff in findings/poc/DF-0630/fix.diff.

Verdict

REPRODUCED and fix VALIDATED. The bug is real exactly as claimed: udp6_ctlinput takes an early return; at sys/netinet6/udp6_usrreq.c:435 (when the quoted UDP header is <4 bytes) that bypasses the out: label and the mandatory lwkt_replymsg at :449. I confirmed in source that so_pr_ctlinput (uipc_msg.c:604-610) dispatches it synchronously via lwkt_domsg with the reply port = the caller's own netisr; lwkt_thread_putport ALWAYS returns EASYNC (lwkt_msgport.c:737, even same-CPU) and lwkt_thread_waitmsg then sleeps in lwkt_sleep until MSGF_DONE (:791) β€” which never arrives because lwkt_replymsg is skipped β€” so the calling netisr blocks forever (self-deadlock on cpu0 since udp6's pr_ctlport=cpu0_ctlport, in6_proto.c:144). Empirically, ONE 48-byte crafted ICMPv6 DestUnreach (inner IPv6 nh=UDP plen=0) injected to ::1 froze the entire guest: baseline ping6 ::1=0.076ms/ssh=0.22s, but after the trigger both ping6 ::1 and a plain ssh round-trip hung past 8s timeouts (rc=124), vm.sh status=>down, and the serial console still showed the live login prompt (NO panic/fatal trap/ddb β€” a permanent netisr wedge, not a crash); the box required vm.sh reset to recover.