cnwrite() constty use-after-free: unsynchronized constty->t_dev captured without a reference and dispatched through dev_doperate() after a sleepable log_console() walk, while an unprivileged user (default UCONSOLE) controls the cdev's lifetime
| Field | Value |
|---|---|
| ID | DF-2896 |
| Status | new |
| Severity | Medium |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:H |
| CWE | CWE-362 / CWE-416 |
| File | sys/kern/tty_cons.c |
| Lines | 465-471 |
| Area | kern |
| Confidence | likely |
| Discovered | 2026-09-02 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | memcorrupt |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
cnwrite() snapshots constty->t_dev with no token and no
reference_dev(), then sleeps and walks the entire write buffer in
log_console() (M_WAITOK kmalloc + per-byte msglogchar β ~100ms for a
4MB write), and only then dispatches dev_doperate() on the captured
cdev. On default kernels (options UCONSOLE β verified live: uid=1001
set constty via TIOCCONS on the stock guest) an unprivileged user owns
that cdev's lifetime: closing the TIOCCONS'd pty pair runs ttyclose β
constty=NULL and pti_done β t_dev=NULL + destroy_dev, freeing the cdev
on the devfs thread β all under tp->t_token, which cnwrite never takes.
A dispatch landing on the freed slot reads si_ops from freed memory:
recycled-mid-init β si_ops==NULL β guaranteed kernel page fault
(panic), or recycled-into-another-pty β privileged console bytes
written into an unrelated user pty (misdirection/leak). Full RIP
control not credible (dedicated cdev objcache recycles kernel-shaped
contents with static ops vectors) β no uid=0 route for this primitive.
Proof of contest
Raced on the guest across 8 harness designs / ~20 min (~7000 teardowns, ~2000 captureβdispatch envelopes): NOT REPRODUCED β the async devfs-thread teardown latency and per-cpu objcache magazine batching decouple the free/realloc transient from the dispatch instants. verdict.json: not_reproduced, window code-proven. Evidence pack: findings/poc/DF-2896/ (racer.c/holder.c/writer.c/gate_test.c + logs).
Recommended fix
Serialize capture+forward against tp->t_token (full git-apply-able fix.diff in findings/poc/DF-2896/).
Timeline
- 2026-09-02 Discovered during pass-2 audit of tty_cons.c (GLM 5.3); raced honestly, not reproduced; fix not testable without baseline.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2896 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 3.5 KB | β raw | |
| VERDICT.md | β | 6.3 KB | β raw | |
| gate_test.c | β | 1.0 KB | view raw | |
| racer.c | β | 3.6 KB | view raw | |
| writer.c | β | 1.4 KB | view raw | |
| holder.c | β | 2.3 KB | view raw | |
| pin.c | β | 512 B | view raw | |
| run_guest.sh | β | 755 B | view raw | |
| fix.diff | β | 1.3 KB | view raw | |
| verdict.json | β | 5.0 KB | view raw | |
| code_hashes.txt | β | 302 B | view raw | |
| logs/ | β | 4.0 KB | β download |
DF-2896 β cnwrite() constty UAF race β evidence pack
What this is
sys/kern/tty_cons.c:cnwrite() (lines 456-472) captures constty->t_dev
(a raw cdev_t, no reference, no token) and then sleeps/walks the entire
write buffer in log_console() (kern/subr_prf.c:259, M_WAITOK kmalloc +
uiomove + one msglogchar per byte) before dispatching dev_doperate() on
the captured cdev. An unprivileged user can control the lifetime of that
cdev on default kernels: options UCONSOLE (sys/config/X86_64_GENERIC:36)
lets any user point constty at their own pty via TIOCCONS
(sys/kern/tty.c:959-977), and closing the pty pair runs
ttyclose() -> constty = NULL (tty.c:251-252), then pti_done() ->
t_dev = NULL, destroy_dev(devs/devc) (kern/tty_pty.c:280-291), which
frees the cdev on the devfs thread. cnwrite holds no lock against any of
this β the window between capture and dispatch is the whole log_console()
walk (milliseconds to seconds, scaling with write size).
Consequences at dispatch through a freed cdev (kern/kern_device.c:544
dev_doperate reads ap->a_dev->si_ops and indirect-calls):
- slot freed, not yet recycled: stale si_ops points at static pty ops ->
ptswrite on a dying tty -> EIO (benign).
- slot recycled mid-init: devfs_new_cdev() bzeroes si_ops
(devfs_core.c:2445) before setting it -> si_ops == NULL ->
NULL-pointer kernel fault -> panic.
- slot recycled into another pty: root's console bytes are written into an
unrelated user pty (misdirected kernel write / information leak).
Gate test (passed)
gate_test.c: unprivileged uid=1001 on the stock guest kernel
(X86_64_GENERIC, UCONSOLE) successfully sets constty via TIOCCONS on its
own pty β the unprivileged lifetime-control side of the race is confirmed:
$ id; /tmp/gate_test uid=1001(maxx) gid=1001(groups=1001) GATE: TIOCCONS OK for uid=1001 (constty now our pty)
Build
On the guest (as root):
cc -O2 -o racer racer.c cc -O2 -pthread -o writer writer.c cc -O2 -o holder holder.c cc -O2 -o pin pin.c
Run
/tmp/df2896/run.sh # starts: racer (unpriv), holder (unpriv),
# writer (root, 1 thread x 4MB /dev/console)
# poll: grep -c HIT /tmp/df2896/holder.log (misdirection detector)
# grep -a "Fatal trap\|panic:" <serial boot.log>
Actors:
- racer (uid 1001): cycles constty β open ptmx/pts, TIOCCONS, hold
12ms (draining the master), close both (teardown), immediately re-attach
constty on the next pty.
- holder (uid 1001): opens 192-pty generations, watches every master for
the writer fill byte 'W'. These ptys are NEVER TIOCCONS'd, so any 'W'
byte proves a dispatch through a recycled stale cdev (misdirection).
- writer (root): 1 thread, 4MB writes to /dev/console, 3/s β each write
captures constty->t_dev, sleeps ~120ms in log_console, then dispatches.
Expected result (success criterion)
Either
- kernel panic ("Fatal trap" / NULL deref in dev_doperate from cnwrite), or
- HIT: N W-bytes on /dev/pts/X (never TIOCCONS'd) in holder.log.
Observed result
NOT REPRODUCED within ~20 minutes of genuine racing (~7000 constty teardowns, ~2000 capture->dispatch envelopes, holder active). No panic, zero misdirection hits. Two earlier hard-downs under heavier load were unattributable (no backtrace; console firehose).
See VERDICT.md for why the window is real but did not manifest on this guest (async devfs teardown latency + dedicated-cdev objcache magazine batching decouple the free/realloc from the dispatch instants).
DF-2896 VERDICT β cnwrite() constty use-after-free race: NOT REPRODUCED (window code-proven)
Status: not_reproduced. reproduced=0, observed impact=none. Structural confidence in the race window: certain (code-proven, cited). Manifestation on this guest within budget: not achieved.
The bug, proven from source
-
Unprivileged lifetime control (default config).
sys/config/X86_64_GENERIC:36βoptions UCONSOLE. With UCONSOLE the privilege check in the TIOCCONS handler is compiled out (sys/kern/tty.c:969-977): any user's pty can becomeconstty. Verified live on the guest:gate_test.crun as uid=1001 returned "TIOCCONS OK". -
Capture without synchronization.
sys/kern/tty_cons.c:465-466:c if (constty) dev = constty->t_dev;cnwrite runs MPSAFE (cn_ops D_MPSAFE, tty_cons.c:71) on the writer's thread with no token;consttyandconstty->t_devare read racily. Compare the mutation sites, all of which run undertp->t_token(tty.c:251-252 ttyclose; tty_pty.c:~405 ptsclose holdspti->pt_tty.t_tokenacross ttyclose+pti_done) β or with no token at all (tty_cons.c:493 cnioctl; subr_prf.c:395 kputchar-on-panic). -
Sleepable gap between capture and use.
tty_cons.c:469-471:log_console(uio)runs between the capture anddev_doperate(&ap->a_head). log_console (subr_prf.c:259-299) doeskmalloc(M_WAITOK)(sleeps), copies the iovec array, then walks the entire write buffer 128 bytes at a time with onemsglogchar()per byte. For a 4MB console write this is a ~100ms+ window; it scales without bound with write size. -
The captured object dies inside that window, attacker-driven. Closing the pty pair: ptsclose -> ttyclose ->
constty = NULL(tty.c:252); then (both sides closed) pti_done ->t_dev = NULL; destroy_dev(devs); destroy_dev(devc)(tty_pty.c:280-291). destroy_dev -> devfs_destroy_dev_worker -> release_dev x3 -> sysref terminate -> the cdev memory returns to the dedicated cdev objcache (devfs_core.c:63-75, sysref class "cdev"). -
Use-after-free dispatch.
dev_doperate()(kern/kern_device.c:544-561) readsap->a_dev->si_opsfrom the (possibly freed) cdev and indirect-calls through it. On slot recycle,devfs_new_cdev()bzeroes the struct up to si_sysref (devfs_core.c:2445) β si_ops is inside that range β and setssi_ops = opsonly afterwards, under the contended devfs lock. A dispatch landing in that transient readssi_ops == NULL-> guaranteed kernel page fault (panic). A dispatch landing after re-init writes root's console data into an unrelated pty (misdirected write); before re-init it dispatches through stale static pty ops into a dying tty (EIO).
Verification attempts (8 harness designs, 2 genuinely-racing configurations)
| run | config | genuine racing? | result |
|---|---|---|---|
| 1 | 6x256KB writers, spray cycler | ~90s (pre-wedge) | console wedged by firehose, no panic text |
| 2-3 | binaries under /root | no β unpriv actor got EACCES (harness bug, found later) | invalid |
| 4 | 2x32MB + holder | racer stuck (0 iters; holder perm denied) | invalid; guest hard-down, no text |
| 5 | 1x4MB + holder | racer status unknown (ssh starved) | guest hard-down, no text |
| 6 | serial-quiet cycler, 1x4MB | yes β 2368 cycles, 704 dispatches | no panic, holder absent (not rebuilt) |
| 7 | same + holder present | yes β 4864 cycles, holder live, ~1300 dispatches | no panic, 0 HITs |
Total genuine exposure: ~7000 teardowns, ~2000 capture->dispatch envelopes each 120ms wide, with active recycling pressure and a deterministic misdirection detector ('W' bytes on never-TIOCCONS'd ptys). No manifestation.
Why it did not manifest (analysis)
- The cdev teardown is asynchronous through the devfs core thread (destroy_dev -> devfs_msg_send_dev). Under pty churn its latency stretches well past a single write's envelope, so most dispatches land while the cdev is dying-but-allocated (stale static ops -> EIO, benign).
- The cdev objcache is dedicated to sizeof(struct cdev) with per-cpu magazines. Frees happen on the devfs thread's cpu; the unprivileged allocator pressure runs on other cpus; slot migration goes through the depot in batches. The recycle (and its ~microsecond si_ops==NULL transient) is therefore only weakly correlated with any given dispatch instant.
- The two observed hard-downs (runs 4-5) left no panic text in the serial log (105MB inspected) and cannot be attributed to this bug rather than to console firehose starvation in general; per the honest classification rules they do not count as reproduction.
Impact ceiling (if manifested)
- Panic (NULL si_ops) β local DoS contributed by an unprivileged user, requires a concurrent privileged /dev/console writer (default syslogd logs *.err;kern.warning to /dev/console).
- Misdirected kernel write of privileged console output into an unrelated user pty.
- Full RIP control is NOT credible: the recycled slot can only be another cdev (dedicated objcache, kernel-initialized contents) and the stale ops pointers are static kernel structures. No attacker-controlled function pointer materializes. uid=0 escalation: not plausible from this primitive alone.
Exploit chain
none demonstrated; see above for the theoretical ceiling.
Fix
fix.diff (this pack): capture constty->t_dev under tp->t_token
(the same token the teardown path holds) and hold the token across the
forward. Fix validation: not_testable β no baseline reproduction
exists to diff against.
kernel_refs
- sys/kern/tty_cons.c:456-472 (cnwrite capture/use, unsynchronized)
- sys/kern/tty_cons.c:71 (D_MPSAFE)
- sys/kern/tty.c:959-977 (UCONSOLE TIOCCONS, no priv check)
- sys/kern/tty.c:251-252 (ttyclose clears constty under t_token)
- sys/kern/tty_pty.c:280-291 (pti_done: t_dev=NULL, destroy_dev)
- sys/kern/subr_prf.c:259-299 (log_console sleeps/walks whole buffer)
- sys/kern/kern_device.c:544-561 (dev_doperate reads si_ops, indirect call)
- sys/vfs/devfs/devfs_core.c:63-75 (dedicated cdev sysref/objcache)
- sys/vfs/devfs/devfs_core.c:2436-2459 (devfs_new_cdev bzero incl. si_ops)
- sys/config/X86_64_GENERIC:36 (options UCONSOLE, default kernel)
Fix verification
not_testableNo baseline reproduction exists to diff against; the fix is source-reviewed only (serializes capture+forward against the exact teardown path that holds tp->t_token across constty=NULL / t_dev=NULL / destroy_dev).
findings/poc/DF-2896/fix.diff
Confirmed kernel references
- sys/kern/tty_cons.c:456-472
- sys/kern/tty_cons.c:71
- sys/kern/tty.c:959-977
- sys/kern/tty.c:251-252
- sys/kern/tty_pty.c:280-291
- sys/kern/subr_prf.c:259-299
- sys/kern/kern_device.c:544-561
- sys/vfs/devfs/devfs_core.c:63-75
- sys/vfs/devfs/devfs_core.c:2436-2459
- sys/config/X86_64_GENERIC:36
Detail
Evidence (decisive lines)
['findings/poc/DF-2896/VERDICT.md β full path:line proof of the window and the attempt matrix', 'findings/poc/DF-2896/gate_test.c β unprivileged TIOCCONS gate test, PASSED on stock guest (uid=1001)', 'findings/poc/DF-2896/racer.c β unpriv constty lifecycle cycler (4864 iterations in final run)', "findings/poc/DF-2896/holder.c β unpriv recycling pressure + misdirection detector ('W' on never-TIOCCONS'd ptys), 0 hits", 'findings/poc/DF-2896/writer.c β root /dev/console writer providing capture->dispatch envelopes', 'findings/poc/DF-2896/logs/ β run summaries, racer/holder/writer logs, serial tail', 'findings/poc/DF-2896/fix.diff β token-serialized capture+forward fix (validated: not_testable, no baseline repro)']
PoC changes
Seed concept rewritten entirely: gate test proving unprivileged UCONSOLE TIOCCONS on the stock guest; serial-quiet constty cycler (immediate re-attach) after the first design wedged the guest with console firehose; dedicated unpriv holder for objcache recycling pressure plus a deterministic misdirection detector (fill byte 'W' appearing on never-TIOCCONS'd ptys); root writer downsized to 1x4MB at 3/s (envelope ~120ms). Three early harness configurations were invalid (unpriv actor EACCES under /root, csh redirect rejection, holder not rebuilt) β found via heartbeat logging and fixed; only the last two runs count as genuine racing.
Verified recommended fix
In cnwrite(), capture constty->t_dev under the constty tty's t_token (the token held by the ttyclose/pti_done teardown path) and hold the token across the dev_doperate() forward; see fix.diff.
Verdict
cnwrite() captures constty->t_dev without any token or cdev reference (sys/kern/tty_cons.c:465-466) and dispatches dev_doperate() on it only after log_console() has slept and walked the whole write buffer (tty_cons.c:469-471, subr_prf.c:259-299). On default kernels (options UCONSOLE, X86_64_GENERIC:36) an unprivileged user controls that cdev's lifetime: TIOCCONS attaches their pty (kern/tty.c:959-977, verified live as uid=1001 via gate_test.c), closing the pair NULLs constty (tty.c:251-252), NULLs t_dev and destroy_dev()s the cdev (tty_pty.c:280-291). The race window (code-proven, milliseconds-to-seconds wide) therefore exists with certainty, but across 8 harness designs and ~20 minutes of genuine racing (~7000 teardowns vs ~2000 capture->dispatch envelopes, with recycling pressure and a deterministic misdirection detector) no manifestation occurred: the async devfs-thread teardown latency and the dedicated per-cpu objcache magazines for struct cdev decouple the free/realloc (with its ~us si_ops==NULL transient, devfs_core.c:2445) from the dispatch instants. Two guest hard-downs under heavier load left no panic text and are unattributable. Realistic ceiling if it ever lands: NULL-deref panic (local DoS requiring a concurrent privileged /dev/console writer, e.g. default syslogd) or a misdirected privileged console write into an unrelated pty; full RIP control is not credible (dedicated cdev objcache, kernel-initialized contents, static ops).
No comments yet.