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

mq_send1 re-reads mq_sig_notify.sigev_signo after releasing mq_mtx: concurrent mq_notify re-registration (SIGEV_NONE bypasses signo validation) smuggles an attacker-chosen int into ksignal β€” panic on INVARIANTS, OOB atomic sigset write on production kernels

Field Value
ID DF-2779
Status new
Severity High
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H
CWE CWE-362 (β†’CWE-787 OOB write)
File sys/kern/sys_mqueue.c
Lines 877 (validate), 885 (release slot), 905 (unlocked re-read), 973-975 (validation gap), 985-988
Area kern
Confidence certain
Discovered 2026-08-31
Pass 2 (GLM 5.3 second pass)
Bucket memcorrupt
Reported pending
Known CVE none
CVE match novel

Summary

mq_send1 validates sigev_notify == SIGEV_SIGNAL under mq_mtx, consumes the registration, releases the lock, then re-reads mq_sig_notify.sigev_signo WITHOUT it for ksignal(). In that window any fd holder may re-register (the slot was just freed): registrations with sigev_notify != SIGEV_SIGNAL never validate sigev_signo, so an arbitrary int reaches ksignal β†’ lwpsignal. Demonstrated twice on the stock kernel: smuggled signo=64 delivered as "Unknown signal: 64" (process killed) in ~15s; smuggled 0x4000001 β†’ panic: lwpsignal: invalid signal 67108865 (stack lwpsignal ← mq_send1 ← syscall2). On non-INVARIANTS kernels the same call runs SIGADDSET_ATOMIC(p->p_siglist, sig) with word index ((sig-1)>>5) unmasked β€” an OOB atomic bit-set write at attacker-chosen offset (8 MB demonstrated; up to ~256 MB).

Threat model & preconditions

Unprivileged local user; race widened deterministically-enough by a closer thread (forcing the sender's fdrop onto the slow last-reference path) plus an mqlist_mtx contender; panic reproduced within one 3-minute run; on production kernels an OOB atomic write primitive.

Proof of concept

VERIFIED on the stock guest (findings/poc/DF-2779/race_signo.c, 8 threads): signo=64 build β†’ "Unknown signal: 64", EXIT=192 in ~15s; signo=0x4000001 build β†’ panic: lwpsignal: invalid signal 67108865, guest down. Fix (snapshot the signo under the lock) authored; validate per pack.

Snapshot the signo under the lock (notify_signo captured at :877, used at :905) β€” three-line diff in the pack.

Timeline

  • 2026-08-31 Discovered during pass-2 audit of sys_mqueue.c (GLM 5.3); unpriv panic + invalid-signal delivery reproduced same run.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2779 Β· 12 files
FileTypeDescriptionSize
race_signo.c β€” 5.6 KB view raw
build.sh β€” 53 B view raw
run.sh β€” 257 B view raw
build.log β€” 151 B view raw
run.log β€” 1.6 KB view raw
panic.txt β€” 2.2 KB view raw
fix_validation.log β€” 0 B ↓ download
fix_validation_b2.log β€” 324 B view raw
VERDICT.md β€” 2.6 KB ↓ raw
verdict.json β€” 3.5 KB view raw
fix.diff β€” 959 B view raw
env.txt β€” 119 B view raw
VERDICT.md
↓ download raw

VERDICT β€” DF-2779: unlocked sigev_signo re-read race β†’ ksignal(p, attacker int)

Status: REPRODUCED Β· impact: panic (INVARIANTS; OOB atomic kernel write on production builds) Β· confidence: certain

What was proven on the guest (stock kernel, build #0 2026-07-02)

Two independent manifestations of the same race:

  1. signo=64 build (~15 s of racing): the racing process was killed by the kernel with "Unknown signal: 64" (EXIT=192 = 128+64) β€” the smuggled, never-validated sigev_signo=64 (registered via SIGEV_NONE, which skips validation at sys_mqueue.c:973-975) reached ksignal() through mq_send1()'s unlocked re-read at sys_mqueue.c:905, was stored by SIGADDSET_ATOMIC (64 < _SIG_MAXSIG=128 β†’ in-set) and delivered to userland.
  2. signo=0x4000001 build (< 180 s of racing): kernel panic panic: lwpsignal: invalid signal 67108865 (67108865 == 0x4000001, the exact attacker constant), stack lwpsignal ← lwpsignal ← mq_send1 ← sys_xsyscall ← syscall2 (panic.txt). Guest down at db>.

On a production (non-INVARIANTS) kernel the same call path executes SIGISMEMBER(p->p_sigignore, sig) and SIGADDSET_ATOMIC(p->p_siglist, sig) with word index ((sig-1)>>5) (kern_sig.c:1183,1367; signal.h:65-67 β€” no bounds mask): an out-of-bounds atomic bit-set write at an attacker-chosen offset up to ~256 MB past the sigset (8 MB for the demonstrated constant).

Race mechanics (why it wins)

Sender consumes the registration and clears mq_notify_proc under mq_mtx, releases the lock, then re-reads mq_sig_notify.sigev_signo without it. The stealer thread (re-registering SIGEV_NONE{bad} / unregistering in a tight loop) acquires the just-released mq_mtx and memcpys its unvalidated sigevent before the sender's read; the closer thread racing close(fd) into the send window forces the sender's internal fdrop() onto the slow last-reference path (spinlock + fo_close → mqlist_mtx/mq_mtx acquisitions), stretching the release→read window from ~100 ns into the microseconds-plus range, and the contender thread keeps mqlist_mtx contended so that slow path sleeps. 8 threads total.

Fix validation (patched kernel, build #1)

fix.diff snapshots notify_signo under mq_mtx. Patched kernel: - build #1 04:10: 600 s, 7.0 M sends / 537 M arm-registrations / 321 M steal-registrations / 145 M closes β€” no panic, clean exit; - build #1 04:57: 600 s, 337 K sends / 675 M arms / 384 M steals β€” no panic, guest up. The window was exercised hundreds of millions of times with zero smuggles.

fix_status: fixed (fix_baseline_reproduced=1, fix_patched_reproduced=0).

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Patched kernel survived two 600-s races (7.0M and 337K sends against 537M/675M arm-registrations and 321M/384M steal-registrations) with no panic and no invalid-signal death; stock kernel panicked in under 180 s.

fix_validation.log, fix_validation_b2.log
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Tue Sep 1 04:57:57 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

armer thread registers SIGEV_SIGNAL{SIGUSR1}; stealer thread loops mq_notify(SIGEV_NONE{bad_signo})/mq_notify(NULL); closer thread races close(fd) into the send window so the sender's fdrop takes the slow last-ref path (stretching the release->read window); contender thread keeps mqlist_mtx contended; sender consumes the registration, releases mq_mtx, and reads the stealer's unvalidated signo -> ksignal(p, attacker int) -> INVARIANTS: KASSERT panic; production: OOB read (SIGISMEMBER p_sigignore) + OOB atomic bit-set write (SIGADDSET_ATOMIC p_siglist, kern_sig.c:1367).

Evidence (decisive lines)

["run.log: run1 'Unknown signal: 64' EXIT=192 in ~15 s", 'panic.txt: panic: lwpsignal: invalid signal 67108865 / mq_send1+0x3ee in trace', 'fix_validation.log: 600 s x 2 patched runs, 7.0M+337K sends vs 700M arms/384M steals, no panic']

PoC changes

raw syscalls; BAD_SIGNO raised from 64 to 0x4000001 after discovering _SIG_MAXSIG=128 (64 is sigset-representable and 'only' kills the process; >=129 gives OOB word index); closer+fdrop-slowpath+lock-contender threads added to widen the ~100 ns window.

Verified recommended fix

Snapshot notify_signo (and the whole sigevent if desired) into a local while mq_mtx is held (fix.diff).

Verdict

Race proven twice on the stock INVARIANTS kernel: (1) smuggled signo=64 (via SIGEV_NONE re-registration, unvalidated at sys_mqueue.c:973-975) was delivered by the kernel as signal 64, killing the racing process ('Unknown signal: 64', exit 192) within ~15 s; (2) smuggled signo=0x4000001 caused 'panic: lwpsignal: invalid signal 67108865' with stack lwpsignal <- mq_send1 <- syscall2 (exact attacker constant in the message; panic.txt). Root cause: mq_send1 checks sigev_notify under mq_mtx (:877) but re-reads sigev_signo after releasing it (:905). On production kernels without INVARIANTS the same ksignal() performs SIGADDSET_ATOMIC at word ((sig-1)>>5) - an OOB atomic bit-set write at attacker-chosen offset (8 MB for the tested constant, up to ~256 MB).