EVFILT_SIGNAL knote use-after-free: knote stays linked into struct proc after the target is reaped; kqueue teardown runs SLIST_REMOVE on freed kernel memory
| Field | Value |
|---|---|
| ID | DF-2691 |
| Status | new |
| Severity | High |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H |
| CWE | CWE-416 Use After Free |
| File | sys/kern/kern_sig.c |
| Lines | 2667-2679 (attach), 2681-2687 (detach), 2696-2705 (filt_signal) |
| Area | kern |
| Confidence | certain |
| Discovered | 2026-08-30 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | memcorrupt |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
filt_sigattach() links the knote into curproc's p_klist with no process
reference; filt_signal() ignores the NOTE_EXIT hint that exit1()
broadcasts (unlike EVFILT_PROC's filt_proc, which detaches itself), so
the signal knote remains linked after kern_wait() kfrees struct proc.
filt_sigdetach() then unconditionally does
knote_remove(&kn->kn_ptr.p_proc->p_klist, kn) on freed memory.
Reached cross-process because rfork(RFPROC) shares the fd table
(fdshare) letting an fd-shared child attach a knote to itself on the
parent's kqueue (fork's fdcopy strips kqueue fds and SCM_RIGHTS
refuses them β both verified live as EBADF/EOPNOTSUPP).
Threat model & preconditions
Unprivileged local user. Deterministic-enough kernel panic (2/2 first-run panics on fresh boots). Beyond the panic: silent write into freed 1280-class slab memory (SLIST_REMOVE_HEAD stores kn->kn_next at offsetof(p_klist)=496 of the freed struct proc) and a kernel pointer-walk over recycled, attacker-influenceable content (demonstrated machinery: p_args argv bytes land in the same slab class with user-chosen bytes at exactly +496). uid0 route (not completed): forge the recycled SLIST so the unlink store becomes an arbitrary-address NULL write.
Proof of concept
findings/poc/DF-2691/kqsig_uaf.c (unprivileged): parent kqueue();
rfork child registers EV_ADD/EVFILT_SIGNAL then _exit(0); parent
waitpid (proc freed, knote dangling); argv-padded exec spray recycles
the freed chunks; close(kq) β Fatal user address access from kernel
mode, Stopped at knote_remove+0x33 (fault 0x18 = offsetof(knote,
kn_next)). Fix (filt_proc-style NOTE_EXIT detach + KN_DETACHED guard)
validated: baseline 2/2 panic β patched 5/5 survive, EVFILT_SIGNAL
functional test passes.
Recommended fix
Mirror filt_proc()'s exit handling in the signal filter (see findings/poc/DF-2691/fix.diff): handle NOTE_EXIT in filt_signal by PHOLD + knote_remove + KN_DETACHED + clear kn_ptr.p_proc; guard filt_sigdetach with KN_DETACHED. Validated in-guest.
Timeline
- 2026-08-30 Discovered during pass-2 audit of kern_sig.c (GLM 5.3); reproduced 2/2 unpriv + fix validated same run.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2691 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| kqsig_uaf.c | β | 5.6 KB | view raw | |
| build.sh | β | 100 B | view raw | |
| run.sh | β | 278 B | view raw | |
| run.log | β | 1.7 KB | view raw | |
| run.2.log | β | 912 B | view raw | |
| panic.txt | β | 904 B | view raw | |
| panic.2.txt | β | 784 B | view raw | |
| env.txt | β | 816 B | view raw | |
| VERDICT.md | β | 5.2 KB | β raw | |
| fix.diff | β | 1.3 KB | view raw | |
| fix_build_excerpt.txt | β | 924 B | view raw | |
| fix_validation.log | β | 1.4 KB | view raw | |
| verdict.json | β | 4.7 KB | view raw | |
| manifest.json | β | 962 B | view raw |
DF-2691 β VERDICT
Bottom line
REPRODUCED. An unprivileged local user can leave an EVFILT_SIGNAL
knote linked into a struct proc that is subsequently freed by wait(),
and the later destruction of the kqueue performs SLIST_REMOVE on the
freed memory. Demonstrated twice on fresh boots of the stock
INVARIANTS kernel as a deterministic-enough kernel panic
(knote_remove+0x33: movq 0x18(%rdx),%rax, fault address 0x18), by an
unprivileged close(2).
How it was reproduced (step by step)
kq = kqueue()in the attacking process P.c = rfork(RFPROC)β without RFFDG, so the child shares P's fd table (fdshare, kern_fork.c:557). This is the only way to reach a kqueue from a process other than its creator, becausefdcopy()strips kqueue fds from forked children (kern_descrip.c:2573-2576) and SCM_RIGHTS refuses them (uipc_usrreq.c:1799-1802).- Child
cregistersEV_ADD, EVFILT_SIGNAL, ident=jonkq;filt_sigattach()(kern_sig.c:2667-2679) links the knote into c'sp_klistwith no reference on c. - Child exits; P reaps it:
kern_wait()βkfree(p, M_PROC)(kern_exit.c:1336).exit1()'sKNOTE(&p->p_klist, NOTE_EXIT)(kern_exit.c:601) does not detach signal knotes (filt_signal()only reacts to the NOTE_SIGNAL bit β kern_sig.c:2698), unlikefilt_proc()which detaches proc knotes on NOTE_EXIT (kern_event.c:382-391). - Repeat 64 times for 64 dangling knotes; spray
execof/bin/sleepwith padded argv sop_argsallocations (same 1280-byte slab class asstruct proc, 1208 bytes) recycle the freed chunks. close(kq)β kqueue drain βfilt_sigdetach()(kern_sig.c:2681-2687) βknote_remove(&kn->kn_ptr.p_proc->p_klist, kn)on freed memory βSLIST_REMOVEwalkscurelm= recycledp_klisthead β fault.
Observed (run 1, cpuid 1 / run 2, cpuid 3):
Fatal user address access from kernel mode from kqsig_uaf at ffffffff8063c973 Fatal trap 12: page fault while in kernel mode fault virtual address = 0x18 current process = 4104 / 872 Stopped at knote_remove+0x33: movq 0x18(%rdx),%rax
0x18 == offsetof(struct knote, kn_next); the faulting instruction is the
while (SLIST_NEXT(curelm, kn_next) != elm) walk with curelm == NULL
(the recycled chunk was a fresh M_ZERO process, kern_fork.c:444).
Why the panic is only the read side (primitive characterization)
- UAF write: when the freed chunk is not recycled,
SLIST_REMOVE_HEADexecuteshead->slh_first = elm->kn_nextβ the kernel writes NULL (or a knote pointer) into freed kernel memory at offset 496 of a 1280-class slab chunk. Silent corruption of whatever later reuses that chunk. - Controlled-pointer walk: when recycled, the kernel loads
SLIST_FIRSTfrom recycled content and dereferences it. The PoC'sp_argsspray demonstrates user-controlled bytes landing at exactly that offset (needs only thekern.ps_arg_cache_limitdebug knob to be raised; without any knob, other same-class kernel allocations decide the value). A recycled headXproduces reads atX+24and, when the walk terminates onelm, a write ofelm->kn_nextthrough the forged link.
Exploit chain (toward uid=0) β analysis, not completed
The write primitive is "NULL or stale-knote-pointer into freed/reused
1280-class chunk at +496" plus "kernel walks a recycled pointer". A full
chain would need to (a) groom the freed chunk with a forged SLIST whose
kn_next chain terminates exactly on the (unknown-address) knote being
removed, converting the final SLIST_REMOVE_AFTER store into an
arbitrary-address NULL-write (e.g. over a struct ucred's
cr_uid/cr_ruid), and (b) defeat the unknown knote address β no KASLR on
this guest, so a heap-address leak or deterministic slab layout would be
the remaining work. Not completed within this verification run; the
deterministic unprivileged panic plus the silent freed-memory write are
already sufficient for High severity.
Fix validation (mandatory for memory-corruption findings)
- Authored
fix.diff:filt_signal()detaches the knote on NOTE_EXIT exactly likefilt_proc()(PHOLD / knote_remove / KN_DETACHED /kn_ptr.p_proc = NULL/ PRELE), andfilt_sigdetach()honors KN_DETACHED. - Applied to the guest's
/usr/src(patch(1) hunks #1 @2681, #2 @2708), rebuilt withmake nativekernel KERNCONF=X86_64_GENERICandmake installkernel(build+install completed β guest/tmp/kbuild.logmarkerBUILD_INSTALL_DONE; log lost to the snapshot reset, excerpt in fix_build_excerpt.txt), rebooted intoDragonFly 6.5-DEVELOPMENT #1: Sun Aug 30 21:53:26 UTC 2026. - Baseline: stock kernel panicked on the first run both times (run.log, run.2.log).
- Patched: the exact same PoC ran 5/5 times to "SURVIVED: no panic" (fix_validation.log).
- Functionality: EVFILT_SIGNAL still delivers on the patched kernel
(func.c:
kevent n=1 ident=30 data=1,FUNC_OK).
fix_status: fixed.
Notes
- Guest was returned to the clean-source snapshot after validation.
- The
kern.ps_arg_cache_limit=8192sysctl (debug knob) is only needed for the content-controlled-recycle demonstration; the panic reproduced without any dependence on its value.
Fix verification
fixedBaseline stock kernel panicked on the first run in both attempts; after applying fix.diff and rebuilding/installing the kernel in-guest, the identical PoC survived 5/5 runs and an EVFILT_SIGNAL functional test passed (kevent returned the signal event). Bad behaviour gone.
['fix_validation.log', 'fix_build_excerpt.txt', 'fix.diff']
Confirmed kernel references
Detail
Exploit chain
rfork(RFPROC) child shares fd table (fdshare) -> child registers EVFILT_SIGNAL knote on parent's kqueue (attaches to child's own p_klist, no proc reference) -> child exits+is reaped -> kfree(p, M_PROC) with knote still linked -> slab recycle of the freed proc chunk (fresh M_ZERO proc => p_klist==NULL; p_args argv spray demonstrates user-controlled content landing at offsetof(p_klist)=496 in the same 1280-byte slab class) -> close(kq) -> kqueue drain -> filt_sigdetach -> knote_remove on freed memory -> SLIST_REMOVE writes into / walks freed memory -> kernel page fault. uid0 escalation not completed: would require forging the recycled SLIST so the walk terminates on the (address-unknown) knote, converting the unlink store into an arbitrary-address NULL write (e.g. over ucred cr_uid/cr_ruid); no KASLR on this guest, remaining work is a knote-address leak/deterministic slab layout.
Evidence (decisive lines)
['run.log / run.2.log - two fresh-boot unprivileged runs, both panic', "panic.txt / panic.2.txt - 'Fatal user address access from kernel mode', Stopped at knote_remove+0x33: movq 0x18(%rdx),%rax", 'fix_validation.log - 5/5 SURVIVED on the fixed kernel', 'fix_build_excerpt.txt - patched kernel #1 build/install markers', 'env.txt - guest kernel geometry (sizeof proc 1208, offsetof p_klist 496, offsetof kn_next 24)', 'VERDICT.md - full narrative incl. primitive characterization and fix validation']
PoC changes
Initial design passed the kqueue fd via fork/SCM_RIGHTS - both are blocked in DragonFly (fdcopy strips kqueue fds; SCM_RIGHTS returns EOPNOTSUPP), discovered as EBADF in the child. Rewritten to rfork(RFPROC) (fd table shared via fdshare), 64 dangling knotes per run, p_args exec-spray for content-controlled recycling (needs kern.ps_arg_cache_limit raised, debug knob only).
Verified recommended fix
filt_signal(): on NOTE_EXIT detach the knote (PHOLD/knote_remove/KN_DETACHED/p_proc=NULL/PRELE) mirroring filt_proc(); filt_sigdetach(): return early when KN_DETACHED is set
Verdict
Unprivileged use-after-free reproduced twice on fresh boots of the stock INVARIANTS kernel: an EVFILT_SIGNAL knote attached by an fd-sharing rfork(RFPROC) child stays linked into the child's p_klist after the child is reaped and its struct proc kfree()d, and the attacker's later close(kq) runs knote_remove()->SLIST_REMOVE() on the freed chunk (panic at knote_remove+0x33, fault 0x18 = offsetof(knote,kn_next), curelm NULL from an M_ZERO-recycled proc). Beyond the panic the primitive is a kernel write into freed 1280-class slab memory (SLIST_REMOVE_HEAD stores elm->kn_next at +496) plus a walk over recycled, attacker-influenceable content. Fix (filt_signal detaches on NOTE_EXIT like filt_proc; filt_sigdetach honors KN_DETACHED) validated by in-guest kernel rebuild: baseline panicked 2/2, patched survived 5/5, EVFILT_SIGNAL still functional.
No comments yet.