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

vconswinch_intr() manipulates tty token-protected state (t_winsize write, t_pgrp read + pgsignal) without tp->t_token β€” INTR_MPSAFE lock-protocol violation with theoretical pgrp use-after-free (self-pinned in practice) and vconsole double-load vs vconsterm

Summary

The INTR_MPSAFE SIGWINCH handler writes t_winsize and loads t_pgrp for pgsignal with no serialization while every other consumer of the same fields holds tp->t_token (the commented-out ttioctl wish at :279-282 shows the manual rewrite forgot it); vconsole is also loaded 4x against vconsterm's NULL store. Rig: pty TIOCSWINSZ storm (~29k/s) + in-vkernel uid-9 pgrp churn (~12-16k installs/s) proved the racy paths execute (stty size flips 0 0->101 80; 956k SIGWINCH vs 543k installs in 45s) with ZERO corruption across >=1e6 opportunities - pgsignal's immediate pgref pin lands ~20ns after the load vs the microseconds-long free path, so the UAF is arithmetically excluded in practice. Filed Low/hardening: protocol violation + torn winsize + teardown NULL window; vkernel-only ceiling. Token+cached-locals fix stress-validated.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5537 Β· 22 files
FileTypeDescriptionSize
README.md β€” 4.8 KB ↓ raw
VERDICT.md β€” 6.7 KB ↓ raw
vkwinch_race.c β€” 5.2 KB view raw
vkwinch_drv.c β€” 8.4 KB view raw
build_image.sh β€” 1.2 KB view raw
build.sh β€” 149 B view raw
run.sh β€” 162 B view raw
fix.diff β€” 1.4 KB view raw
env.txt β€” 894 B view raw
logs/vk5537-probe.log β€” 3.2 KB view raw
logs/vk5537-full3.log β€” 12.4 MB ↓ download
logs/vk5537-full5.log β€” 3.5 KB view raw
logs/vk5537-full5.drv β€” 3.8 KB ↓ download
logs/vk5537-full6.log β€” 3.6 KB view raw
logs/vk5537-full6.drv β€” 3.8 KB ↓ download
logs/vk5537-churn6.log β€” 6.0 KB view raw
logs/vk5537-stormctl.log β€” 3.1 KB view raw
logs/vk5537-stormctl.drv β€” 3.3 KB ↓ download
logs/vk5537-fixed-full.log β€” 3.5 KB view raw
logs/vk5537-fixed-full.drv β€” 3.7 KB ↓ download
logs/vk5537-fixed-probe.log β€” 3.2 KB view raw
logs/df5537-fixbuild.log β€” 936.0 KB ↓ download

DF-5537 β€” vkernel64 vconswinch_intr() missing tty token

What (the defect)

sys/platform/vkernel64/platform/console.c:272-289 β€” the SIGWINCH interrupt handler vconswinch_intr() (registered INTR_MPSAFE at console.c:374-377, so it runs concurrently with normal threads on other CPUs) manipulates ttyv0's token-protected state without holding tp->t_token:

if (vconsole != NULL && vconsole->cn_dev->si_tty != NULL) {        /* :276 */
        ioctl(0, TIOCGWINSZ, &newsize);
        /* ttioctl(...TIOCSWINSZ...); I wished.  Unfortunately this
           needs a curproc, so do it manually.  */                  /* :279 */
        if (bcmp(&newsize, &...->t_winsize, sizeof(newsize)) != 0) {/* :283 */
                ...->t_winsize = newsize;                          /* :285 */
                pgsignal(...->t_pgrp, SIGWINCH, 1);                /* :286 */
        }
}

Every other consumer of t_winsize / t_pgrp holds tp->t_token: vconsvirt_intr() takes it (console.c:307), ttioctl()'s TIOCSWINSZ (tty.c:1239-1243), TIOCSPGRP/TIOCSCTTY (tty.c:1205-1233), ttyclearsession() (tty.c:283-292). The commented-out line at console.c:279-282 shows the author knew the manual version needed the same serialization β€” and forgot it. Additionally vconsole is loaded four separate times (:276,:283,:285,:286) against vconsterm()'s vconsole = NULL (:399) β€” a NULL-deref window during console teardown.

What was verified on the guest (QEMU DFly 6.5-DEVELOPMENT)

Rig (proven DF-5337/DF-5289 family rig, upgraded): VKERNEL64 kernel.debug booted on a pty controlling terminal (/vkroot.img root, -n 2 vCPUs), so the driver can inject an arbitrarily fast SIGWINCH storm by alternating TIOCSWINSZ rows 100↔101 on the pty master. Inside the vkernel, an unprivileged (uid 9) racer churns ttyv0's foreground process group at full rate (setpgid(0,0) β†’ TIOCSPGRP(&pg) β†’ park), forcing tp->t_pgrp swaps + pgrel()β†’kfree(M_PGRP) ~12k/s.

Proven: 1. The vulnerable path executes: stty size on the console goes 0 0 β†’ 101 80 across a storm (vconswinch_intr ran and wrote t_winsize); the churn child received 956,614 SIGWINCH in 45 s β€” i.e. the token-less pgsignal(tp->t_pgrp, …) fired ~21k/s against the cycling pgrps (logs/vk5537-probe.log, logs/vk5537-full6.log). 2. The free side runs: 543,388/1,439,696 pgrp installs with the previous (empty) pgrp pgrel'd per swap β€” zero ioctl errors (logs/vk5537-full6.log, logs/vk5537-full5.log). 3. No corruption manifested: full-rate storm+churn for 45–90 s (β‰₯10⁢ racy reads against β‰₯5Γ—10⁡ frees), 3 independent runs β€” vkernel alive, racer completes cleanly. Controls (churn-only, storm-only) also clean.

Why the UAF does not manifest in practice: pgsignal()'s first action is pgref(pgrp) (kern_sig.c:917) β€” the token-less load of tp->t_pgrp is pinned ~20 ns later, while the racing free path (tp->t_pgrp = new store β†’ pgrel β†’ 1β†’0 β†’ LIST_REMOVE β†’ kfree) takes microseconds; the dangerous interleaving (pin landing after kfree) is arithmetically excluded in practice. The remaining exposure is the raw t_winsize data race and the vconsole/vconsterm double-load β€” protocol defects, not demonstrated memory corruption.

Verdict

status: not_reproduced (memory-safety impact), impact: none β€” the lock-protocol violation itself is proven present and exercised (items 1-2 above); the UAF it theoretically permits is self-pinned and survived ~10⁢ forced opportunities without manifesting. Severity Low (hardening / correctness); fix recommended for protocol compliance.

Fix

fix.diff: take tp->t_token around the winsize update + pgsignal, cache vconsole/cn_dev->si_tty in locals. Fix-validated on the guest: patched vkernel survives the identical stress (689k installs, 1.86M storm ops, 1.39M SIGWINCH delivered, zero errors β€” see logs/vk5537-fixed-full.log) and console winch propagation still works (stty size 0 0 β†’ 100 80, logs/vk5537-fixed-probe.log). No baseline-bad/patched-good delta exists to demonstrate because the corruption never manifested on the unpatched kernel either.

Reproduce

On the audit guest (see build.sh / run.sh):

# one-time: build vkernel + image + tools
sh build.sh
# decisive runs
VKCON=/tmp/vkconsole.log /root/vkwinch_drv full 90     # race attempt
VKCON=/tmp/vkconsole.log /root/vkwinch_drv churn 30    # control
VKCON=/tmp/vkconsole.log /root/vkwinch_drv plain 20    # control + stty probe

Success criterion for the violation: stty size changes across the storm and [race child] … sigwinch=<large> in the console log. Success criterion for the corruption (never observed): Fatal trap/panic/pgrel assertion in the vkernel under full mode.

VERDICT.md
↓ download raw

DF-5537 β€” VERDICT

status: not_reproduced (for the memory-corruption impact); the locking-protocol violation itself is proven present and exercised on the guest. impact: none demonstrated. Severity Low (hardening/protocol correctness).

Root cause (path:line)

  • sys/platform/vkernel64/platform/console.c:272-289 β€” vconswinch_intr() reads tp->t_winsize (bcmp), writes it, and reads tp->t_pgrp + pgsignal()s it without tp->t_token.
  • Registration is INTR_MPSAFE with a NULL serializer (console.c:374-377) β†’ the handler runs on an LWKT interrupt thread concurrent with user threads on other vCPUs; by the MPSAFE contract it must self-synchronize β€” it doesn't.
  • Every legitimate consumer holds the token: vconsvirt_intr() (console.c:307-327, the sibling handler in the same file), ttioctl() TIOCSWINSZ (sys/kern/tty.c:1239-1243), TIOCSPGRP/TIOCSCTTY (tty.c:1205-1233), ttyclearsession() (tty.c:283-292).
  • Secondary: vconsole is loaded 4Γ— (console.c:276,283,285,286) against vconsterm()'s vconsole = NULL (console.c:399) β€” NULL-deref window if the console is torn down while a winch interrupt is in flight.
  • Trigger: any SIGWINCH on the vkernel process's controlling terminal (host-side TIOCSWINSZ on the pty) β†’ vconswinchsig (console.c:266) β†’ signalintr(3) β†’ vconswinch_intr.

Rig (guest: DragonFly 6.5-DEVELOPMENT #0 Thu Jul 2 2026, 6 CPUs)

VKERNEL64 kernel.debug (same-tree build) booted with its console on a pty slave installed as its controlling terminal (setsid+TIOCSCTTY in vkwinch_drv.c), -m 512m -r /vkroot.img -n 2. The driver alternates TIOCSWINSZ (rows 100↔101) on the pty master β€” each change makes the guest kernel SIGWINCH the pty's foreground pgrp (= the vkernel process), driving the vulnerable handler at will.

In-vkernel racer (vkwinch_race.c, uid 9): grabs ttyv0 as ctty, drops all privileges, then one parent+child pair churns the foreground pgrp: child setpgid(0,0) β†’ hand pgid to parent β†’ parent ioctl(ttyfd, TIOCSPGRP, &pg) (swaps tp->t_pgrp under the token, pgrel()s the previous empty pgrp β†’ kfree(M_PGRP), kern_proc.c:657-693) β†’ ack β†’ child parks. SIGTTOU/SIGTTIN are ignored so the orphaned-background EIO check (tty.c:885-896) doesn't kill the churn; TIOCSPGRP takes a pointer to the pgid (tty.c:1210). No privilege is required: isctty() is same-session + P_CONTROLT only (sys/sys/tty.h:216-218).

What was demonstrated

  1. Handler executes and is racy-by-construction β€” logs/vk5537-probe.log: stty size on the console reports 0 0 before a storm and 101 80 after β†’ vconswinch_intr wrote t_winsize (console.c:285) token-lessly.
  2. Racy pgsignal hits the cycling pgrps at full rate β€” logs/vk5537-full6.log: [race child] done: cycles=543388 errors=0 sigwinch=956614 (45 s) β€” ~21k token-less pgsignal(tp->t_pgrp,…) executions per second against pgrps being created/freed at ~12k/s.
  3. Free side confirmed β€” every one of 1,439,696 installs (logs/vk5537-full5.log, 90 s) replaced (and pgrel'd) the previous pgrp; racer reports zero errors; vkernel ALIVE at end.
  4. No corruption manifests β€” full storm+churn: 3 clean runs (45 s, 90 s, 60 s-fixed); churn-only and storm-only controls clean. Aggregate: β‰₯10⁢ racy reads against β‰₯5Γ—10⁡ frees, no panic, no assertion, no allocator message, no behavioral anomaly.

Why the UAF is effectively unreachable through this path

pgsignal() pins the pointer immediately (kern_sig.c:914-929): pgref(pgrp) is its first action (~20 ns after the token-less load at console.c:286). The racing free path β€” tp->t_pgrp = new store β†’ pgrel() 1β†’0 transition β†’ LIST_REMOVE β†’ sess_rele β†’ kfree (kern_proc.c:657-693) β€” spans microseconds after the store. For a use-after-free the pin would have to land after the kfree, i.e. the handler's loadβ†’pin gap would have to straddle the entire storeβ†’free interval: with loadβ†’pin β‰ˆ 20 ns and storeβ†’free β‰ˆ 1-3 Β΅s, the dangerous interleaving is arithmetically excluded; the pin instead prevents the 1β†’0 transition (pgrel's cmpset retries at 2β†’1) and the object is freed safely from pgsignal's own bracketing pgrel. Contrast DF-5337 (userenter crhold race), which reproduced in seconds because its loadβ†’atomic-add window was not followed by a pin and the churn was in-kernel.

An earlier run (logs/vk5537-full3.log) saw the vkernel die silently with console output frozen mid-heartbeat β€” post-mortem: that build of the racer had zero successful TIOCSPGRPs (EIO from the orphaned background check, tty.c:885-896) and was instead flooding the console with ~40k dprintf/s under storm; attributed to console flooding, not the pgrp race. The corrected racer (full5/full6) with identical storm+churn but quiet logging survives cleanly.

Exploit chain

None developed β€” the primitive (token-less read of a refcounted pointer) is real but self-pinning; the only demonstrated effects are the protocol violation itself and a benign t_winsize data race. No path to uid=0 was found through this defect on this rig.

Fix validation

fix.diff (token + cached locals) applied to the guest's /usr/src, VKERNEL64 rebuilt (logs/df5537-fixbuild.log, BUILD_RC=0), rerun: * full stress on fixed kernel: 689,332 installs, 1,856,551 storm ops, 1,389,672 SIGWINCH delivered to the churn pgrp, zero errors, vkernel ALIVE (logs/vk5537-fixed-full.log/.drv) β€” note SIGWINCH delivery still works, i.e. pgsignal under the token did not starve. * no functional regression: stty size 0 0 β†’ 100 80 across a storm on the fixed kernel (logs/vk5537-fixed-probe.log).

Because the corruption never reproduced on the baseline, the fix run cannot show a bad→good delta; fix_status: not_testable (nothing to diff), with the no-regression evidence above recorded.

Rig quirks documented for reproduction

  • The vkernel's get_line() (sys/kern/vfs_conf.c:566-583) treats an empty non-blocking cncheckc() as EOF and panics at mountroot> β€” the root spec ufs:vkd0 must be continuously pre-fed on the console from exec until "Mounting root from ufs:vkd0" appears (vconsinit's TCSAFLUSH, console.c:460, flushes early feeds).
  • TIOCSPGRP/TIOCGPGRP take a pointer; passing the value gives EFAULT.
  • An orphaned background pgrp gets instant EIO from ttioctl's background check (tty.c:885-896) unless SIGTTOU is ignored.

Fix status (batch validation)

Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.

Fix verification

not_testable
baseline no→ patch + rebuild →patched clean

fix.diff applied to guest /usr/src and VKERNEL64 rebuilt (BUILD_RC=0); patched vkernel survived the identical full stress (689,332 installs, 1,856,551 storm ops, 1,389,672 SIGWINCH delivered, zero errors, VKRESULT: ALIVE) and console winch propagation still works (stty size 0 0 -> 100 80) -- no regression. No bad->good delta can be demonstrated because the baseline corruption never manifested (pin analysis in VERDICT.md); hence not_testable rather than fixed.

logs/df5537-fixbuild.log; logs/vk5537-fixed-full.log; logs/vk5537-fixed-full.drv; logs/vk5537-fixed-probe.log
↓ fix.diffper-fix-DF-5537

Confirmed kernel references

Detail

Evidence (decisive lines)

findings/poc/DF-5537/: VERDICT.md (full narrative); logs/vk5537-probe.log (stty 0 0 -> 101 80 = handler executes); logs/vk5537-full6.log ([race child] done: cycles=543388 errors=0 sigwinch=956614 + SUMMARY installs=543388 errors=0 = both race sides at full rate, vkernel alive); logs/vk5537-full5.log (90s run: installs=1439696, alive); logs/vk5537-stormctl.log/.drv and logs/vk5537-churn6.log (controls clean); logs/vk5537-fixed-full.log + logs/vk5537-fixed-probe.log (fix-validated, no regression); logs/df5537-fixbuild.log (BUILD_RC=0)

PoC changes

Seed concept (SIGWINCH vs pgrp churn) kept, but the rig was rebuilt substantially: vkernel booted on a pty controlling terminal (not a FIFO) so TIOCSWINSZ storms reach it; racer rewritten 3x -- final version is a single parent+child pair (no fork-per-install), parks the child in the parent's pgrp for pgrp re-creation, passes a POINTER to TIOCSPGRP (value gives EFAULT), and ignores SIGTTOU/TTIN (orphaned-background ioctls otherwise get instant EIO at tty.c:885-896); driver pre-feeds 'ufs:vkd0' continuously because get_line() (vfs_conf.c:566-583) panics on an empty cncheckc poll at mountroot>.

Verified recommended fix

Take tp->t_token around the t_winsize write and the t_pgrp pgsignal in vconswinch_intr(), caching vconsole/cn_dev->si_tty in locals (see fix.diff).

Verdict

The lock-protocol violation is real and was exercised at full rate on the guest: vconswinch_intr() (console.c:272-289, INTR_MPSAFE) reads/writes ttyv0's t_winsize and reads t_pgrp for pgsignal without tp->t_token, unlike its sibling vconsvirt_intr() (console.c:307) and every ttioctl consumer (tty.c:1239-1243, 1205-1233, 283-292). A pty-based rig proved the handler executes (stty size 0 0 -> 101 80 across a host TIOCSWINSZ storm) and that its token-less pgsignal fired ~21k/s against foreground pgrps an unprivileged (uid 9) in-vkernel racer was swapping/freeing at ~12k/s (956,614 SIGWINCH received; 543,388/1,439,696 successful TIOCSPGRP installs). Across >=1e6 racy reads vs >=5x10^5 pgrp frees (45-90s full-rate runs plus churn-only and storm-only controls) NO memory corruption, panic, assertion or anomaly ever manifested: pgsignal()'s first action pgref()s the loaded pointer ~20ns after the load, while the racing free path (store -> pgrel 1->0 -> LIST_REMOVE -> kfree) takes microseconds, so the pin virtually always lands before the free and the dangerous interleaving (pin after kfree) is arithmetically excluded in practice. The defect therefore stands as a hardening/protocol-correctness bug (missing token + vconsole double-load vs vconsterm NULL) with a theoretical-but-unreachable UAF, not a demonstrated memory-corruption primitive. fix.diff (token + cached locals) builds, survives the identical stress with winch propagation still functional (stty 0 0 -> 100 80), no regression.