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

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).

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2896 Β· 12 files
FileTypeDescriptionSize
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).

VERDICT.md
↓ download raw

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

  1. 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 become constty. Verified live on the guest: gate_test.c run as uid=1001 returned "TIOCCONS OK".

  2. 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; constty and constty->t_dev are read racily. Compare the mutation sites, all of which run under tp->t_token (tty.c:251-252 ttyclose; tty_pty.c:~405 ptsclose holds pti->pt_tty.t_token across ttyclose+pti_done) β€” or with no token at all (tty_cons.c:493 cnioctl; subr_prf.c:395 kputchar-on-panic).

  3. Sleepable gap between capture and use. tty_cons.c:469-471: log_console(uio) runs between the capture and dev_doperate(&ap->a_head). log_console (subr_prf.c:259-299) does kmalloc(M_WAITOK) (sleeps), copies the iovec array, then walks the entire write buffer 128 bytes at a time with one msglogchar() per byte. For a 4MB console write this is a ~100ms+ window; it scales without bound with write size.

  4. 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").

  5. Use-after-free dispatch. dev_doperate() (kern/kern_device.c:544-561) reads ap->a_dev->si_ops from 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 sets si_ops = ops only afterwards, under the contended devfs lock. A dispatch landing in that transient reads si_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

Fix verification

not_testable
baseline no→ patch + rebuild →patched clean

No 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
↓ fix.diffper-fix-DF-2896

Confirmed kernel references

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).