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

netmsg_so_notify abort path derives lock, list and reply identity from nm_so, which soaccept_predicate mutates mid-flight on a foreign CPU: unserialized abort β†’ wrong-list TAILQ_REMOVE, illegal pool-token release, double reply

Field Value
ID DF-2851
Status new
Severity High
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:C/C:H/I:H/A:H
CWE CWE-362 β†’ CWE-822/787-class corruption
File sys/kern/uipc_msg.c
Lines 702-751 (mutation uipc_syscalls.c:261)
Area kern
Confidence certain
Discovered 2026-09-02
Pass 2 (GLM 5.3 second pass)
Bucket memcorrupt
Reported pending
Known CVE none
CVE match novel

Summary

kern_accept blocks on a stack netmsg_so_notify queued on head->so_rcv.ssb_mlist. soaccept_predicate reassigns msg->base.nm_so to the just-accepted socket ("Abuse nm_so field ... XXX JH") before its caller replies. When the completion runs cross-CPU (soisconnected β†’ sorwakeup(head) on the connection's netisr β†’ sowakeup MEVENT scan), an unprivileged user can abort the same message concurrently (signal β†’ lwkt_abortmsg β†’ netmsg_so_notify_doabort β†’ netmsg_so_notify_abort). The abort takes lwkt_getpooltoken(nmsg->base.nm_so) β€” pool tokens are address-hashed, so the post-mutation token does NOT serialize with the completing CPU β€” passes the DONE|REPLY recheck before MSGF_REPLY is set, computes ssb from the mutated nm_so, executes TAILQ_REMOVE on a list the message was never queued on, plus lwkt_replymsg(EINTR) racing the completion's own reply.

Threat model & preconditions

Unprivileged local user: blocking accept(2) + signal (PCATCH abort) + connection flood steering completion CPUs. INVARIANTS: deterministic panic (reproduced 2/2, ≀2 min). Non-INVARIANTS: wrong-list TAILQ_REMOVE with stale tqe_prev/tqe_next corrupts ssb_mlist/tqh_last of both sockets; subsequent notify inserts / sowakeup scans write through dangling pointers, plus a double lwkt_replymsg (fatal per the lwkt_msgport contract) β€” kernel heap memory corruption, escalation surface; floor is reliable local DoS. uid=0 grooming not pursued on the INVARIANTS guest (asserts at the token release before the corruption can be steered).

Proof of contest

VERIFIED (findings/poc/DF-2851/accept_abort_race.c, unpriv): 8 blocking accept threads + 2 SIGUSR1 killers (no SA_RESTART) + 6 connector threads β†’ panic: lwkt_reltoken: illegal release with backtrace lwkt_reltoken ← netmsg_so_notify_abort+0xe9 ← netmsg_service_loop (run#1 <20s, run#2 ~2min after fresh reset). Fix (nm_result output field, nm_so immutable) validated on a rebuilt kernel: 2Γ—240s of identical stress (~45k accepts, ~359k aborted notifies, 6.5M connects) with zero panics.

Validated 3-file fix.diff in findings/poc/DF-2851/.

Timeline

  • 2026-09-02 Discovered during pass-2 audit of uipc_msg.c (GLM 5.3); unpriv panic reproduced 2/2 + fix validated same run.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2851 Β· 16 files
FileTypeDescriptionSize
README.md β€” 1.9 KB ↓ raw
VERDICT.md β€” 7.9 KB ↓ raw
accept_abort_race.c β€” 4.5 KB view raw
build.sh β€” 155 B view raw
run.sh β€” 174 B view raw
build.log β€” 1.0 KB view raw
run.log β€” 1.7 KB view raw
panic.txt β€” 740 B view raw
panic_run2.txt β€” 740 B view raw
boot_full.txt β€” 2.3 KB view raw
boot_full_run2.txt β€” 2.3 KB view raw
fixrun.log β€” 378 B view raw
env.txt β€” 498 B view raw
fix.diff β€” 2.8 KB view raw
manifest.json β€” 1.2 KB view raw
verdict.json β€” 5.4 KB view raw

DF-2851 PoC β€” netmsg_so_notify abort race (accept path)

What it shows

An unprivileged user racing signal-aborted blocking accept(2) against concurrent TCP connection completions panics the stock INVARIANTS kernel inside netmsg_so_notify_abort() (sys/kern/uipc_msg.c) because soaccept_predicate() reassigns msg->base.nm_so while the notify message is still abortable, so the abort path locks/lists the wrong socket. On non-INVARIANTS kernels the same interleaving is a wrong-list TAILQ_REMOVE + double lwkt_replymsg (kernel heap corruption).

Build (inside guest, unprivileged ok)

cc -O2 -pthread -Wall -o /tmp/accept_abort_race /tmp/accept_abort_race.c

Run (inside guest, unprivileged)

/tmp/accept_abort_race 240

Expected

Kernel panic within seconds-to-minutes:

LWKT_RELTOKEN ASSERTION ...
lwkt_reltoken: release wants pool and got pool
panic: lwkt_reltoken: illegal release
lwkt_reltoken() at lwkt_reltoken+0xda
lwkt_reltoken() at lwkt_reltoken+0xda
netmsg_so_notify_abort() at netmsg_so_notify_abort+0xe9
netmsg_service_loop() at netmsg_service_loop+0xe9

(observe with vm.sh log). Observed: run #1 panicked in <20 s, run #2 (fresh reset) in ~2 min. With fix.diff applied and kernel rebuilt: no panic over 2x240 s.

Files

accept_abort_race.c   PoC source (listeners/acceptors/killers/connectors)
build.sh / run.sh     exact commands
build.log             compiler output
run.log               decisive run transcript (run #2)
panic.txt             run #1 panic (serial console)
panic_run2.txt        run #2 panic (serial console, fresh reset)
boot_full*.txt        full serial console captures
env.txt               guest environment
fix.diff              git-apply-able fix (validated in-guest)
VERDICT.md            full narrative
manifest.json         machine catalog
verdict.json          machine verdict
VERDICT.md
↓ download raw

DF-2851 β€” netmsg_so_notify abort race: nm_so mutated by accept predicate β†’ unserialized abort on wrong socket (wrong-list TAILQ_REMOVE / illegal token release / double reply)

Verdict in one paragraph

REPRODUCED (panic), unprivileged, 2/2 stress runs on the stock INVARIANTS kernel (#0 X86_64_GENERIC, 6 vCPU). An unprivileged local user racing a signal-aborted blocking accept(2) against concurrent connection completion drives netmsg_so_notify_abort() (sys/kern/uipc_msg.c:734) into operating on a different socket than the one it locked, because soaccept_predicate() reassigns msg->base.nm_so (uipc_syscalls.c:261) while the notify message is still queued and abortable. On the INVARIANTS guest this panics deterministically-enough (lwkt_reltoken: illegal release inside netmsg_so_notify_abort); on a production (non-INVARIANTS) kernel the same interleaving executes TAILQ_REMOVE() against the wrong socket's ssb_mlist and a second lwkt_replymsg() on an already-replied message β€” kernel heap list corruption with stale tqe_prev/tqh_last pointers plus a double reply. Fix validated: with the one-line root fix (dedicated nm_result output field, nm_so never mutated) the same stress runs clean for 2Γ—240 s where the unfixed kernel panicked in ≀2 min.

Full narrative

The machinery

DragonFly's kern_accept() blocks on a stack-allocated netmsg_so_notify message (uipc_syscalls.c:293,345-350) dispatched via lwkt_domsg(head->so_port, ..., PCATCH) to the listener's netisr thread. netmsg_so_notify() (uipc_msg.c:652) runs the predicate under the head's pool token; if unsatisfied it queues the message on head->so_rcv.ssb_mlist and sets SSB_MEVENT.

Two independent parties can later complete (reply) the queued message:

  1. netmsg_so_notify itself (same netisr thread that owns head->so_port) β€” safe vs. abort by port FIFO ordering.
  2. sowakeup() (uipc_socket2.c:616-630) β€” the SSB_MEVENT scan β€” which runs on whatever CPU the connection completed on: soisconnected() β†’ sorwakeup(head), explicitly noted as cross-CPU in uipc_socket2.c ("XXX head may be on a different protocol thread. sorwakeup()->sowakeup() is hacked atm."). For TCP the completing CPU is chosen by the 4-tuple hash β€” an attacker spraying connect(2) from different source ports steers it at will.

The defect

On the completion path, soaccept_predicate() reassigns the message's socket identity:

/* uipc_syscalls.c:255-262 */
soreference(so);
so->so_head = NULL;
...
msg->base.lmsg.ms_error = 0;
msg->base.nm_so = so;          /* <-- "Abuse nm_so field ... XXX JH" */
return (TRUE);

after which sowakeup() removes the message from head's mlist and calls lwkt_replymsg() (which sets MSGF_REPLY atomically).

Meanwhile the blocked acceptor can be signalled (PCATCH): lwkt_waitmsg β†’ lwkt_abortmsg β†’ netmsg_so_notify_doabort (uipc_msg.c:702) β†’ abort message to head->so_port β†’ netmsg_so_notify_abort (uipc_msg.c:734), which derives both its interlock token and its list from the mutable field:

/* uipc_msg.c:744-751 */
lwkt_getpooltoken(nmsg->base.nm_so);                 /* (a) token of X */
if ((nmsg->base.lmsg.ms_flags & (MSGF_DONE | MSGF_REPLY)) == 0) {
        ssb = (nmsg->nm_etype & NM_REVENT) ?
                &nmsg->base.nm_so->so_rcv :          /* (b) list of Y */
                &nmsg->base.nm_so->so_snd;
        TAILQ_REMOVE(&ssb->ssb_mlist, nmsg, nm_list);/* wrong list */
        lwkt_relpooltoken(nmsg->base.nm_so);
        lwkt_replymsg(&nmsg->base.lmsg, EINTR);      /* double reply */
}

lwkt_getpooltoken() is an address-hashed token pool (lwkt_token.c:816-823): head and the just-accepted so hash to different pool slots, so once nm_so has been reassigned the abort takes a token that does not serialize with the completing CPU's sowakeup (which holds head's slot across its predicate→remove→reply sequence). The DONE|REPLY recheck at uipc_msg.c:745 therefore races the completion's lwkt_replymsg, and every use of nm_so inside the critical section can observe a different socket than the one locked at (a).

Observed manifestations

  • INVARIANTS guest (this PoC): nm_so changed between (a) and the release β†’ the abort releases a pool token it never acquired: panic: lwkt_reltoken: illegal release, backtrace lwkt_reltoken ← lwkt_reltoken ← netmsg_so_notify_abort ← netmsg_service_loop (panic.txt, panic_run2.txt). Note that in the TRUE branch the TAILQ_REMOVE at uipc_msg.c:749 executes before the panicking release β€” the wrong-list unlink already happened.
  • Non-INVARIANTS kernels: no token assertion fires. The abort (i) unlinks nmsg from accepted_so->so_rcv.ssb_mlist, a list it was never on β€” with standard TAILQ_REMOVE (no TRASHIT poisoning outside QUEUEDEBUG, sys/sys/queue.h:139) the stale tqe_prev/tqe_next from head's list are re-unlinked, corrupting tqh_last/links of one or both lists (subsequent netmsg_so_notify inserts and sowakeup scans then write through dangling pointers), and (ii) replies the message with EINTR racing the completion's own lwkt_replymsg (double reply β€” the KKASSERT at lwkt_msgport.c:595 exists precisely because double replies are fatal to the wait machinery).

Reproduction

  • PoC: accept_abort_race.c (8 blocking acceptors on a loopback TCP listener, 2 killer threads delivering SIGUSR1 with sa_flags=0 β€” no SA_RESTART, so every delivery aborts the in-flight notify message β€” and 6 connector threads completing connections). All unprivileged (maxx, uid 1001).
  • Run #1 (pre-reset guest): panic within 20 seconds.
  • Run #2 (fresh vm.sh reset with-src boot): panic within ~2 minutes (see run.log, panic_run2.txt). 2/2 runs panicked.

Exploitability

  • Who: any local unprivileged user (sockets + signals only).
  • What: on non-INVARIANTS kernels, a cross-list TAILQ_REMOVE with stale pointers (write *(tqe_prev) = tqe_next into another socket's notify list, plus tqh_last corruption) and a double message reply. The corrupted ssb_mlist is exercised by subsequent accepts/wakeups (TAILQ_INSERT_TAIL writes through *tqh_last), giving a repeatable, partially-controlled write primitive into kernel heap objects β€” a genuine memory-corruption escalation surface, though racing it into a controlled uid=0 chain was not pursued within this run's budget (the guest panics long before the list corruption can be groomed on this INVARIANTS kernel). Impact ceiling: kernel memory corruption β†’ privilege escalation; floor: reliable local DoS (panic).
  • The window is soaccept_predicate's nm_so = so … completion's atomic_set(MSGF_REPLY) β€” tens of ns wide, but both event streams are user-paced and the stress hit it within minutes on 6 vCPUs.

Fix validation (see fix section of manifest)

  • Baseline: unfixed kernel panics ≀2 min into the stress (2/2).
  • Patched: fix.diff (nm_result output field; nm_so immutable) applied in the guest, make nativekernel KERNCONF=X86_64_GENERIC, reboot into the patched kernel, same PoC run 2Γ—240 s: no panic, guest up, clean summary lines. The defect class is closed by making the abort path's lock/list identity immutable; the double-reply and wrong-list-remove interleavings cannot occur because nm_so no longer changes after queueing.

kernel_refs

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

With fix.diff applied (nm_result output field; base.nm_so immutable for the message lifetime) the identical stress workload ran 2x240s with zero panics where the unpatched kernel panicked within 2 minutes; the abort path can no longer derive lock/list identity from a concurrently-mutated field.

['findings/poc/DF-2851/fixrun.log (2x240s clean runs with full counters)', 'findings/poc/DF-2851/build.log (kernel build/install transcript, kernel #1)', 'findings/poc/DF-2851/panic.txt, panic_run2.txt (baseline panics)']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Wed Sep 2 10:31:50 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

socket/listen -> N threads blocked in accept(2) (stack netmsg_so_notify queued on head->so_rcv.ssb_mlist) -> signal flood (SIGUSR1, sa_flags=0) drives netmsg_so_notify_doabort/abort on the listener's netisr while -> connector flood steers connection completion onto a different netisr CPU -> soisconnected/sorwakeup(head) runs soaccept_predicate, reassigns nm_so to the accepted socket in the window before MSGF_REPLY is set -> abort path takes the accepted socket's pool token (different hash slot), passes DONE|REPLY recheck, TAILQ_REMOVEs the message from the accepted socket's ssb_mlist (never queued there) and double-replies. INVARIANTS: panic at token release (reproduced). non-INVARIANTS: corrupted ssb_mlist/tqh_last + stale-pointer unlink writes -> subsequent netmsg_so_notify inserts / sowakeup scans write through dangling pointers -> memory-corruption escalation surface.

Evidence (decisive lines)

["panic.txt / panic_run2.txt: 'panic: lwkt_reltoken: illegal release' with backtrace lwkt_reltoken <- lwkt_reltoken <- netmsg_so_notify_abort+0xe9 <- netmsg_service_loop (runs #1 and #2, #2 after fresh vm.sh reset with-src)", 'run.log: full transcript of the decisive run #2 (unprivileged maxx, ~2 min to panic)', 'fixrun.log: post-fix 2x240s stress on kernel #1 (with fix.diff): accepts=25734/19742, eintr=226130/132855, no panic', 'VERDICT.md: full interleaving analysis with path:line citations', 'boot_full*.txt: complete serial console captures']

PoC changes

Original analysis PoC designed fresh for this finding (no seed). Tuned: 8 blocking acceptors, 2 killer threads (SIGUSR1, sa_flags=0 => EINTR, no SA_RESTART), 6 connector threads on 127.0.0.1, killers usleep(300us) so accepts still get to block; run detached via nohup with output files because ssh-run foreground invocations wedge when the kernel panics.

Verified recommended fix

Never mutate netmsg_so_notify.base.nm_so while the message is abortable: carry the accepted socket in a new nm_result output field (netmsg.h + soaccept_predicate + kern_accept) so the abort path's token/list identity is immutable.

Verdict

Unprivileged local user panics the stock INVARIANTS kernel (2/2 stress runs, <=2 min) by racing a signal-aborted blocking accept(2) against concurrent connection completion: soaccept_predicate reassigns msg->base.nm_so (uipc_syscalls.c:261) while the notify message is still abortable, so netmsg_so_notify_abort (uipc_msg.c:744-751) locks/lists/replies against the wrong socket, unserialized against the foreign-CPU sowakeup completion. Observed: 'panic: lwkt_reltoken: illegal release' in netmsg_so_notify_abort; the wrong-list TAILQ_REMOVE at uipc_msg.c:749 executes before the panicking release. On non-INVARIANTS kernels the same interleaving yields wrong-list TAILQ_REMOVE with stale tqe pointers plus double lwkt_replymsg (kernel heap corruption); uid=0 chain not pursued within budget on this INVARIANTS guest which asserts first. Fix validated in-guest: patched kernel survived 2x240s of the identical stress (~45k accepts, ~359k aborted notifies, 6.5M connects) with zero panics.