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

logtimeout() use-after-free read of sc_sigio: pgsigio() on a struct sigio freed by a concurrent FIOSETOWN ioctl

Field Value
ID DF-2953
Status new
Severity Low
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:N/A:H
CWE CWE-362 β†’ CWE-416 / CWE-476
File sys/kern/subr_log.c
Lines 247-248, 292-301 (free side: kern_descrip.c:1239-1272)
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

The periodic softclock callout logtimeout() loads logsoftc.sc_sigio and calls pgsigio() on the raw pointer with no sigio_token and no reference. A concurrent FIOSETOWN/TIOCSPGRP ioctl on the open klog fd calls fsetown(), which under sigio_token frees the old sigio via funsetown(). pgsigio() then dereferences the freed chunk: pgref of the NULLed sio_pgrp and CANSIGIO's (sio_ucred)->cr_uid on the NULLed ucred. VERIFIED on the guest: Fatal trap 12, fault address 0x40, instruction pointer pgsigio+0xcd, current process Idle (softclock thread), within one second of PoC launch β€” reproduced twice. Every other klog-path accessor (fsetown/funsetown/fgetown) holds sigio_token; logtimeout is the only unlocked reader. logclose's funsetown is NOT a racer β€” callout_terminate waits for the in-progress callback. Host root gets a reliable local DoS; combined with DF-0190 (no jail check), a jailed root on a host whose devfs ruleset exposes /dev/klog can panic the HOST kernel from inside the jail. Read-side UAF on a dedicated M_SIGIO zone with NULLed freed fields β€” practical primitive is deterministic panic, not a write; uid0 not pursued (trigger already privileged). FreeBSD fixed this class by passing struct sigio ** to pgsigio with internal locking. Fix validated: driver-local struct lock interlocking every sc_sigio access β€” baseline panics <1s; fixed kernel survived identical 300s race soak. Do NOT use lwkt tokens here (empirically refuted: tokens drop when the holder blocks β€” the same broken pattern exists at sys_pipe.c:211-214, noted as a latent instance for sys_pipe.c).

Timeline

  • 2026-09-02 Discovered during pass-2 audit of subr_log.c (GLM 5.3); panic reproduced <1s twice + fix validated (300s soak clean).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2953 Β· 14 files
FileTypeDescriptionSize
klog_sigio_race.c β€” 3.2 KB view raw
build.sh β€” 117 B view raw
run.sh β€” 585 B view raw
build.log β€” 5.6 MB ↓ download
run.log β€” 2.8 KB view raw
panic.txt β€” 870 B view raw
run2_brokenfix_panic.txt β€” 1.7 KB view raw
env.txt β€” 492 B view raw
fix.diff β€” 2.5 KB view raw
fix.attempt2-systemic-sigio-ref.diff.unvalidated β€” 27.0 KB ↓ download
VERDICT.md β€” 7.9 KB ↓ raw
README.md β€” 3.0 KB ↓ raw
manifest.json β€” 1.4 KB view raw
verdict.json β€” 6.2 KB view raw

DF-2953 β€” /dev/klog logtimeout() use-after-free read of sc_sigio

What

sys/kern/subr_log.c:247-248 β€” the periodic logtimeout() softclock callout loads logsoftc.sc_sigio without sigio_token and passes the raw struct sigio * to pgsigio():

if ((logsoftc.sc_state & LOG_ASYNC) && logsoftc.sc_sigio != NULL)
        pgsigio(logsoftc.sc_sigio, SIGIO, 0);

A concurrent FIOSETOWN / TIOCSPGRP ioctl on the (open) klog fd calls fsetown() (sys/kern/subr_log.c:292-301 β†’ sys/kern/kern_descrip.c:1296), which β€” under sigio_token β€” replaces and frees the old sigio (funsetown(), sys/kern/kern_descrip.c:1239-1272: sio_pgrp=NULL, sio_ucred=NULL, kfree(sigio, M_SIGIO)).

pgsigio() (sys/kern/kern_sig.c:2639-2662) then dereferences the freed chunk: sigio->sio_pgid, sigio->sio_pgrp β†’ pgref(NULL), and CANSIGIO(sigio->sio_ruid, sigio->sio_ucred, p) β†’ (sigio->sio_ucred)->cr_uid on a NULL/stale ucred (sys/kern/kern_sig.c:99-104).

All other accessors of a sc_sigio-style pointer (fsetown, funsetown, fgetown) hold sigio_token; logtimeout is the only unlocked reader in the tree's klog path. (FreeBSD fixed this class by passing struct sigio ** to pgsigio and locking internally.)

The logclose() β†’ funsetown() path is NOT a racer: callout_terminate() synchronously waits for an in-progress callback (sys/kern/kern_timeout.c:1104-1126, wait loop at :926-941).

Impact

Kernel NULL-pointer deref panic in the softclock thread (verified: fatal trap 12, fault virtual address = 0x40, Stopped at pgsigio+0xcd). If the freed M_SIGIO chunk is recycled before the deref, the kernel reads attacker-adjacent slab data and follows garbage sio_pgrp/sio_ucred pointers. Trigger requires an open fd on /dev/klog (mode 0600 root:wheel, sys/kern/subr_log.c:317): host root, or jailed root where a devfs ruleset exposes klog (the DF-0190 jail-check gap) β€” the latter turns this into a host-kernel panic from inside a jail.

Reproduce (guest is single-tenant, stock INVARIANTS kernel)

# build (in guest, as root)
cc -O2 -Wall -o /root/klog_sigio_race /root/klog_sigio_race.c
# run (as root)
/etc/rc.d/syslogd stop            # syslogd holds /dev/klog
sysctl -w kern.log_wakeups_per_second=100
/root/klog_sigio_race

Expected (vulnerable kernel): panic within seconds β€” Fatal trap 12: page fault while in kernel mode, fault virtual address = 0x40, Stopped at pgsigio+0xcd: movl 0x40(%rdx),%edx, current process = Idle (softclock thread).

Mechanism of the PoC: opens klog, sets FIOASYNC (LOG_ASYNC), becomes a process-group leader with 16 members (widens pgsigio()'s member loop β†’ microsecond-scale window), one child hammers kill(-pg, 0) for pg_lock contention, one child writes to /dev/console in a loop (log_console() sets msgbuftrigger=1, sys/kern/subr_prf.c:296), and three processes hammer ioctl(FIOSETOWN, -pgid) / ioctl(FIOSETOWN, 0) (install pgrp-owner sigio / free it).

Fixed kernel: identical run completes cleanly (no panic).

VERDICT.md
↓ download raw

DF-2953 β€” VERDICT

Status: REPRODUCED (impact: panic / local kernel DoS). Fix validated on guest (baseline panics <1 s; patched kernel survives a 300 s race soak).

What was audited and found

Pass-2 audit of sys/kern/subr_log.c (/dev/klog driver, 321 lines). Beyond the four known findings (DF-0189/0190/0191/0192 β€” not re-reported), one new memory-safety race was found and proven:

logtimeout() β€” the periodic softclock callout β€” reads logsoftc.sc_sigio and calls pgsigio(logsoftc.sc_sigio, SIGIO, 0) at sys/kern/subr_log.c:247-248 without sigio_token and without a reference, while

pgsigio() (sys/kern/kern_sig.c:2639-2662) then dereferences the freed chunk: sigio->sio_pgid, pgref(sigio->sio_pgrp) with sio_pgrp == NULL, and CANSIGIO(sigio->sio_ruid, sigio->sio_ucred, p) whose first evaluation is (uc)->cr_uid (kern_sig.c:99-104) on the NULLed ucred.

Every other accessor of a sigio * pointer in the klog path (fsetown/funsetown/fgetown) holds sigio_token (kern_descrip.c:1246,1368,1391); logtimeout is the only unlocked reader. The logclose() β†’ funsetown() combination is not a racer: callout_terminate() synchronously waits for an in-progress callback (sys/kern/kern_timeout.c:1104-1126, wait loop at :926-941). FreeBSD fixed this class years ago by passing struct sigio ** to pgsigio with internal locking; DragonFly still passes the raw pointer.

Reproduction (run 1 β€” baseline)

Guest: DragonFly 6.5-DEVELOPMENT, stock INVARIANTS kernel #0 Thu Jul 2 06:02:54 UTC 2026. PoC klog_sigio_race.c (root): opens /dev/klog (after stopping syslogd), sets FIOASYNC, becomes a process-group leader with 16 members (widens pgsigio()'s member loop to a microsecond-scale window), one child hammers kill(-pgrp, 0) (pg_lock contention stretches pgsigio()), one child writes /dev/console in a loop (log_console() sets msgbuftrigger = 1, subr_prf.c:296), and three processes hammer ioctl(FIOSETOWN, -pgid) / ioctl(FIOSETOWN, 0) (install pgrp-owned sigio / free it).

Result: panic in under one second β€”

Fatal trap 12: page fault while in kernel mode
cpuid = 3; lapic id = 3
fault virtual address   = 0x40          <- (NULL ucred/pgrp) + field offset
instruction pointer     = 0x8:0xffffffff8066212d  (pgsigio+0xcd)
current process         = Idle          <- softclock kernel thread
current thread          = pri 12
Stopped at      pgsigio+0xcd:   movl    0x40(%rdx),%edx

The faulting context (Idle/softclock, inside pgsigio, constant small fault address) matches the source-level prediction exactly: dereference of funsetown()-NULLed fields in a freed struct sigio.

Exploitability ceiling

Honest classification: panic (local DoS). Triggering requires an open fd on /dev/klog (mode 0600 root:wheel, subr_log.c:317), i.e. host root β€” or jailed root where a devfs ruleset exposes klog (the DF-0190 jail-check gap), which turns this into a host-kernel panic from inside a jail. The read-side UAF on the M_SIGIO chunk is not straightforwardly convertible to a write primitive: the zone is dedicated to struct sigio, and the post-free field values are NULLed pointers (hence the deterministic NULL deref). A recycled chunk yields at most a spurious SIGIO to a stale target. uid0 escalation was not pursued further because the trigger is already privileged.

Fix validation (mandatory for memory-corruption class)

  • Attempt 1 β€” hold sigio_token across pgsigio() in logtimeout: rebuilt, same PoC, panicked identically. Root cause: lwkt tokens are soft β€” "If the thread blocks all tokens are released, then reacquired when the thread resumes" (sys/kern/lwkt_token.c:40-42) β€” and pgsigio() blocks in lockmgr(&pg->pg_lock). Note the tree already uses this broken pattern at sys/kern/sys_pipe.c:211-214 (related latent instance, out of this file's scope β€” recommend the orchestrator file it against sys_pipe.c).
  • Attempt 2 β€” systemic pgsigio(struct sigio **) rewrite (snapshot pgid/ruid/ucred/proc/pgrp + crhold/PHOLD/pgref under shared sigio_token, 13 files): rebuilt, same PoC, panicked with assertion "count > 0" failed in sess_rele from pgrel inside the new pgsigio β€” the snapshotted pgrp still hit a destroy/destroy race (suspected lwkt shared/exclusive exclusion subtleties; root cause not fully determined). Kept as fix.attempt2-systemic-sigio-ref.diff.unvalidated; not shipped.
  • Final fix (shipped as fix.diff) β€” driver-local: add a real struct lock sc_lock to logsoftc, lockinit() in log_drvinit, and take it LK_EXCLUSIVE around every klog-path access of sc_sigio (logopen fsetown, logclose funsetown, logtimeout pgsigio, FIOSETOWN/TIOCSPGRP ioctls). lockmgr locks are held across blocking, which is exactly the property the token approaches lacked. Lock order is strictly sc_lock β†’ {sigio_token, pg_token, p_token}; sc_lock appears nowhere else, so no inversion is possible.
  • Run 4 (validated): kernel #1 Fri Sep 4 03:14:52 UTC 2026 (clean build, build.log), identical PoC under timeout 300: no panic; guest stayed up, PoC exited normally at the timeout, load average 5.04 during the soak confirms the race machinery was fully exercised. Baseline died in ≀1 s on the same workload β€” the behavioral delta is decisive.

Negative results (classes hunted and killed in this pass)

  • logread OOB: impossible β€” every access is bounded by lindex % msg_size and n = min(msg_size - lindex_modulo, xindex - lindex, uio_resid) (subr_log.c:171-182); wrap/torn reads are the DF-0192 data-integrity family only.
  • msg_size < 2048 corner in the wrap-correction (subr_log.c:163-164): unreachable at runtime β€” msg_size is fixed at boot from compile-time MSGBUF_SIZE (1 MB default; machdep.c:2557, msgbufinit), never resized.
  • FIONREAD truncation: n ≀ msg_size - 1024 uncorrected or ≀ msg_size - 2048 corrected β€” fits int (subr_log.c:277-282).
  • logtimeout re-arm after callout_terminate: closed β€” terminate waits for INPROG (kern_timeout.c:926-941); stray trailing callout_reset can only occur before termination completes, and the !log_open guard makes any post-close firing benign (subr_log.c:238-239).
  • hz / log_wakeups_per_second negative/huge values: root-only sysctl; callout clamps to β‰₯1 tick (kern_timeout.c:788-791) β€” worst case is CPU burn by root; the 0 case is DF-0191 (not re-reported).
  • sysctl_kern_msgbuf (subr_prf.c:1153-1198) branch 3 (n - rindex_modulo): modular arithmetic makes the branch unreachable except in the corrected corner (xindex_modulo == 0, rindex_modulo == 2048) where the copy stays in-bounds (truncating only). No OOB.
  • msgbuf_clear racing logread: cursor may regress and the reader may see bzero'd bytes β€” data integrity only, all accesses still modulo-bounded; root-only CTLFLAG_SECURE sysctl. DF-0192 family.
  • Missed wakeup in logread's sleep loop: self-heals on the next kprintf (msgbuftrigger re-arms), PCATCH allows signal interruption. Liveness nit only.
  • sc_state plain store in logclose racing atomic bit ops: hint bits only, benign.
  • kqfilter/knote lifecycle: logsoftc is static, knotes reference no per-open data; fd-close detaches via logfiltdetach. No lifetime bug.
  • logopen callout_init_mp on an armed callout: only reachable via the DF-0189 double-open (same root cause, amplifier noted there).
  • TIOCSPGRP privilege: fsetown enforces same-session policy (kern_descrip.c:1326,1341) β€” no cross-session signaling.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Baseline (stock) kernel panicked at pgsigio+0xcd within 1s of PoC launch (twice, plus once under a broken fix attempt). Kernel built with the driver-local sc_lock fix survived the identical PoC under timeout 300 with no panic (load avg 5.04 during soak; PoC exited cleanly at timeout; guest up). Two intermediate fix approaches were built and empirically refuted (lwkt token across pgsigio - drops on block per lwkt_token.c:40-42; systemic 13-file snapshot rewrite - still hit a pgrp destroy race, root cause undetermined); both are documented and the systemic diff is retained as unvalidated.

['run.log run 4: NO PANIC, guest up, pgrep=0 after timeout, load 5.04', 'build.log: fixed-kernel build completed 0 errors', 'fix.diff: the validated 6-hunk driver-local fix', 'run2_brokenfix_panic.txt / run.log runs 2-3: refuted fix attempts']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Fri Sep 4 03:14:52 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

root (or jailed root w/ klog devfs exposure): open /dev/klog; FIOASYNC=1; FIOSETOWN(-own_pgrp) installs pgrp-owned sigio; 16 pgrp members + killpg(0) hammer stretch pgsigio()'s member loop to microseconds; /dev/console write loop keeps msgbuftrigger set so logtimeout (hz/wakeups per sec) calls pgsigio; concurrent FIOSETOWN(0)/FIOSETOWN(-pgrp) hammer frees the sigio inside pgsigio's deref window -> NULL-deref panic in softclock. Ceiling: local DoS (panic); read-side UAF of a dedicated M_SIGIO chunk with NULLed pointers - no practical write primitive.

Evidence (decisive lines)

['panic.txt: Fatal trap 12, fault virtual address=0x40, instruction pointer=pgsigio+0xcd, current process=Idle (softclock), Stopped at pgsigio+0xcd: movl 0x40(%rdx),%edx - baseline panic, <1s after launch', 'run.log: all four runs (baseline panic <1s; attempt1 token fix panic; attempt2 systemic fix panic sess_rele assert; final fix 300s clean soak, load 5.04, guest up)', "run2_brokenfix_panic.txt: identical panic on the token-wrapped 'fix' proving lwkt tokens drop on block (lwkt_token.c:40-42)", 'fix.diff: shipped driver-local lockmgr fix (subr_log.c, 6 hunks) - validated', 'fix.attempt2-systemic-sigio-ref.diff.unvalidated: 13-file pgsigio(struct sigio**) snapshot rewrite - built+ran, still raced, NOT shipped', 'build.log: final fixed-kernel build, 0 errors (Kernel build for X86_64_GENERIC completed Fri Sep 4 03:19:15 UTC 2026)', 'klog_sigio_race.c: PoC source; build.sh/run.sh exact commands']

PoC changes

PoC authored fresh for this finding (no seed). Widening strategies that made the sub-microsecond window deterministic: (1) pgrp owner instead of proc owner so pgsigio takes the long pg_lock path; (2) 16 forked pgrp members stretch the signal loop; (3) killpg(pgrp,0) hammer contends pg_lock inside pgsigio; (4) /dev/console write loop keeps msgbuftrigger set so every logtimeout tick signals; (5) kern.log_wakeups_per_second raised to 100 (must stay >=1 - 0 is DF-0191); (6) three FIOSETOWN hammer processes for CPU spread.

Verified recommended fix

Serialize every klog-driver access of sc_sigio with a driver-local lockmgr lock held across the blocking pgsigio() path (fix.diff in this pack); alternatively upstream a reference-counted pgsigio(struct sigio **) that does not rely on lwkt tokens across blocking.

Verdict

logtimeout() (softclock callout) reads logsoftc.sc_sigio without sigio_token and calls pgsigio() on the raw pointer (sys/kern/subr_log.c:247-248); concurrent FIOSETOWN/TIOCSPGRP on the klog fd frees the struct sigio (fsetown->funsetown->kfree, kern_descrip.c:1239-1372) and pgsigio then dereferences NULLed/freed fields (pgref(NULL), (NULL ucred)->cr_uid via CANSIGIO, kern_sig.c:99-104,2639-2662). Verified on the guest: kernel panic (Fatal trap 12, fault 0x40, pgsigio+0xcd, softclock/Idle context) within one second of PoC launch. Trigger requires an open /dev/klog fd (root:wheel 0600), so host-root DoS; via DF-0190 (jail root + devfs-exposed klog) it becomes a host-kernel panic from inside a jail. Validated fix: driver-local lockmgr sc_lock serializing every sc_sigio access in logopen/logclose/logtimeout/FIOSETOWN/TIOCSPGRP; patched kernel survived a 300s race soak (baseline died <1s). Two earlier fix attempts (lwkt token around pgsigio; systemic 13-file pgsigio(struct sigio**) snapshot rewrite) were built, re-panicked, and are documented as not-shipped.