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

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.

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2691 Β· 14 files
FileTypeDescriptionSize
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
VERDICT.md
↓ download 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)

  1. kq = kqueue() in the attacking process P.
  2. 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, because fdcopy() strips kqueue fds from forked children (kern_descrip.c:2573-2576) and SCM_RIGHTS refuses them (uipc_usrreq.c:1799-1802).
  3. Child c registers EV_ADD, EVFILT_SIGNAL, ident=j on kq; filt_sigattach() (kern_sig.c:2667-2679) links the knote into c's p_klist with no reference on c.
  4. Child exits; P reaps it: kern_wait() β†’ kfree(p, M_PROC) (kern_exit.c:1336). exit1()'s KNOTE(&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), unlike filt_proc() which detaches proc knotes on NOTE_EXIT (kern_event.c:382-391).
  5. Repeat 64 times for 64 dangling knotes; spray exec of /bin/sleep with padded argv so p_args allocations (same 1280-byte slab class as struct proc, 1208 bytes) recycle the freed chunks.
  6. 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_REMOVE walks curelm = recycled p_klist head β†’ 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_HEAD executes head->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_FIRST from recycled content and dereferences it. The PoC's p_args spray demonstrates user-controlled bytes landing at exactly that offset (needs only the kern.ps_arg_cache_limit debug knob to be raised; without any knob, other same-class kernel allocations decide the value). A recycled head X produces reads at X+24 and, when the walk terminates on elm, a write of elm->kn_next through 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)

  1. Authored fix.diff: filt_signal() detaches the knote on NOTE_EXIT exactly like filt_proc() (PHOLD / knote_remove / KN_DETACHED / kn_ptr.p_proc = NULL / PRELE), and filt_sigdetach() honors KN_DETACHED.
  2. Applied to the guest's /usr/src (patch(1) hunks #1 @2681, #2 @2708), rebuilt with make nativekernel KERNCONF=X86_64_GENERIC and make installkernel (build+install completed β€” guest /tmp/kbuild.log marker BUILD_INSTALL_DONE; log lost to the snapshot reset, excerpt in fix_build_excerpt.txt), rebooted into DragonFly 6.5-DEVELOPMENT #1: Sun Aug 30 21:53:26 UTC 2026.
  3. Baseline: stock kernel panicked on the first run both times (run.log, run.2.log).
  4. Patched: the exact same PoC ran 5/5 times to "SURVIVED: no panic" (fix_validation.log).
  5. 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=8192 sysctl (debug knob) is only needed for the content-controlled-recycle demonstration; the panic reproduced without any dependence on its value.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Baseline 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']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Sun Aug 30 21:53:26 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

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.