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)
PoC verification
Evidence pack
findings/poc/DF-5537 Β· 22 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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.
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()readstp->t_winsize(bcmp), writes it, and readstp->t_pgrp+pgsignal()s it withouttp->t_token.- Registration is
INTR_MPSAFEwith 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:
vconsoleis loaded 4Γ (console.c:276,283,285,286) againstvconsterm()'svconsole = 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
TIOCSWINSZon 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
- Handler executes and is racy-by-construction β
logs/vk5537-probe.log:
stty sizeon the console reports0 0before a storm and101 80after βvconswinch_intrwrotet_winsize(console.c:285) token-lessly. - 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-lesspgsignal(tp->t_pgrp,β¦)executions per second against pgrps being created/freed at ~12k/s. - 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.
- 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-blockingcncheckc()as EOF and panics atmountroot>β the root specufs:vkd0must be continuously pre-fed on the console from exec until "Mounting root from ufs:vkd0" appears (vconsinit'sTCSAFLUSH, console.c:460, flushes early feeds). TIOCSPGRP/TIOCGPGRPtake 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_testablefix.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
Confirmed kernel references
- sys/platform/vkernel64/platform/console.c:272-289
- sys/platform/vkernel64/platform/console.c:307-327
- sys/platform/vkernel64/platform/console.c:374-381
- sys/platform/vkernel64/platform/console.c:396-401
- sys/kern/tty.c:1239-1243
- sys/kern/tty.c:1205-1233
- sys/kern/tty.c:283-292
- sys/kern/tty.c:885-896
- sys/kern/kern_sig.c:914-929
- sys/kern/kern_proc.c:657-693
- sys/sys/tty.h:216-218
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.
No comments yet.