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.
Recommended fix
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)
PoC verification
Evidence pack
findings/poc/DF-2779 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 β 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:
- 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-validatedsigev_signo=64(registered viaSIGEV_NONE, which skips validation atsys_mqueue.c:973-975) reachedksignal()throughmq_send1()'s unlocked re-read atsys_mqueue.c:905, was stored bySIGADDSET_ATOMIC(64 <_SIG_MAXSIG=128 β in-set) and delivered to userland. - signo=0x4000001 build (< 180 s of racing): kernel panic
panic: lwpsignal: invalid signal 67108865(67108865 == 0x4000001, the exact attacker constant), stacklwpsignal β lwpsignal β mq_send1 β sys_xsyscall β syscall2(panic.txt). Guest down atdb>.
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
fixedPatched 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
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).
No comments yet.