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.
Recommended fix
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)
PoC verification
Evidence pack
findings/poc/DF-2851 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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
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:
- netmsg_so_notify itself (same netisr thread that owns
head->so_port) β safe vs. abort by port FIFO ordering. - sowakeup() (uipc_socket2.c:616-630) β the
SSB_MEVENTscan β 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 sprayingconnect(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_sochanged between (a) and the release β the abort releases a pool token it never acquired:panic: lwkt_reltoken: illegal release, backtracelwkt_reltoken β lwkt_reltoken β netmsg_so_notify_abort β netmsg_service_loop(panic.txt, panic_run2.txt). Note that in the TRUE branch theTAILQ_REMOVEat 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
nmsgfromaccepted_so->so_rcv.ssb_mlist, a list it was never on β with standardTAILQ_REMOVE(no TRASHIT poisoning outside QUEUEDEBUG, sys/sys/queue.h:139) the staletqe_prev/tqe_nextfromhead's list are re-unlinked, corruptingtqh_last/links of one or both lists (subsequentnetmsg_so_notifyinserts andsowakeupscans then write through dangling pointers), and (ii) replies the message with EINTR racing the completion's ownlwkt_replymsg(double reply β theKKASSERTat 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 withsa_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-srcboot): 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_REMOVEwith stale pointers (write*(tqe_prev) = tqe_nextinto another socket's notify list, plustqh_lastcorruption) and a double message reply. The corruptedssb_mlistis exercised by subsequent accepts/wakeups (TAILQ_INSERT_TAILwrites 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'snm_so = soβ¦ completion'satomic_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 becausenm_sono longer changes after queueing.
kernel_refs
- sys/kern/uipc_msg.c:702-715 (netmsg_so_notify_doabort reads nm_so)
- sys/kern/uipc_msg.c:744-751 (token + ssb + TAILQ_REMOVE + reply from mutable nm_so)
- sys/kern/uipc_syscalls.c:244-262 (soaccept_predicate reassigns base.nm_so)
- sys/kern/uipc_socket2.c:616-630 (sowakeup foreign-cpu MEVENT scan replies)
- sys/kern/uipc_socket2.c:239-283 (soisconnected β sorwakeup(head), "may be on a different protocol thread")
- sys/kern/lwkt_token.c:816-823 (pool tokens are address-hashed β no cross-socket serialization)
- sys/kern/lwkt_msgport.c:595 (double-reply KKASSERT), :184-203 (domsg sync contract)
Fix verification
fixedWith 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)']
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.
No comments yet.