TIOCSCTTY reassignment orphans the old tty's t_session, yielding an unprivileged use-after-free of struct session (deterministic panic; ctty hijack of recycled sessions)
| Field | Value |
|---|---|
| ID | DF-2687 |
| Status | new |
| Severity | High |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:C/C:L/I:L/A:H |
| CWE | CWE-416 Use After Free (CWE-667 secondary) |
| File | sys/kern/tty.c |
| Lines | 1190-1199 (missing dissociation), 294-296 (ttyclearsession deref), 1623-1624 (ttymodem) |
| Area | kern |
| Confidence | certain |
| Discovered | 2026-08-30 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | memcorrupt |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
ttioctl() TIOCSCTTY (tty.c:1181-1204) moves s_ttyp to the new tty and
ttyunhold()s the old one but never clears the old tty's
t_session/t_pgrp β every other s_ttyp transition is paired with
ttyclearsession (:279-316). Because the vnode layer
(devfs_vnops.c:1598-1611) accepts a pty MASTER fd for TIOCSCTTY,
making the master vnode s_ttyvp, the master's last close clears
s_ttyvp via devfs' controlling-tty half-close (:1153-1156) without
running ttyclose() (ptcclose never calls it), leaving tp->t_session set
while the guard at :1183-1185 now passes (s_ttyvp == NULL). The leader
then re-TIOCSCTTYs a second tty and exits; sess_rele()
(kern_proc.c:930-947) repairs only sess->s_ttyp->t_session and kfrees
the session, so the first tty's t_session dangles into freed M_SESSION
memory. Closing the orphaned slave runs ttyclearsession β
lwkt_gettoken(&prg->proc_token) on freed memory (:294-296);
ttymodem()'s ksignal(tp->t_session->s_leader, SIGHUP) (:1623-1624)
is the same class. Additionally leaks a pgrp reference per trigger and
enables ctty hijack: the freed 104-byte M_SESSION chunk is
LIFO-recycled by the next setsid(), making the orphaned tty act as that
unrelated session's controlling terminal (isctty matches; cross-session
TIOCSPGRP/TIOCSWINSZ pgsignal reach).
Threat model & preconditions
Any local unprivileged user with access to /dev/ptmx. Reproduced:
deterministic kernel panic (Fatal trap 9, lwkt_gettoken+0x64) from uid
1002 with debug.use_weird_array=1 making the freed-chunk read fault;
without the flag the same unconditional deref silently consumes stale
freed-memory contents. Ceiling beyond DoS: session-structure recycling
gives attacker-influenced t_session targets (ksignal on chosen
s_leader where ttymodem is reachable; cross-session signal delivery and
job-control hijack), plus permanent kernel state corruption.
Proof of concept
findings/poc/DF-2687/df2687.c (unprivileged): open ptmx+pts; fork
leader; parent drops master; leader setsid β ioctl(master, TIOCSCTTY) β
close(master) β open pty2 + TIOCSCTTY (orphans tp1) β exit (session
kfreed); parent closes the orphaned slave β panic. hold mode +
kern.ttys decode shows the orphaned TS_ZOMBIE tty with a non-NULL freed
session pointer. uid0 route not completed (documented: M_SESSION LIFO
recycle β t_session aliases a chosen live session β ctty hijack;
ksignal sink gated by devfs invisibility of closed clone masters on the
stock guest).
Recommended fix
Dissociate the replaced tty inside TIOCSCTTY (token-guarded t_session/t_pgrp clear + pgrel) and reject TIOCSCTTY on D_MASTER fds (devfs_fo_ioctl / vn_ioctl). Full diff in findings/poc/DF-2687/fix.diff. Hunk 1 validated in-guest (nativekernel build + reboot): identical PoC + poison β no panic, kern.ttys shows the orphan fully dissociated.
Timeline
- 2026-08-30 Discovered during pass-2 audit of tty.c (GLM 5.3); deterministic unpriv panic reproduced + fix validated same run.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2687 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| df2687.c | β | 7.3 KB | view raw | |
| forensic.c | β | 1.2 KB | view raw | |
| build.sh | β | 291 B | view raw | |
| run.sh | β | 452 B | view raw | |
| build.log | β | 753 B | view raw | |
| run.log | β | 1.2 KB | view raw | |
| run.fix.log | β | 402 B | view raw | |
| panic.txt | β | 630 B | view raw | |
| forensic.baseline.txt | β | 661 B | view raw | |
| forensic.fixed.txt | β | 349 B | view raw | |
| env.txt | β | 536 B | view raw | |
| fix.diff | β | 2.3 KB | view raw | |
| FIXNOTES.md | β | 2.4 KB | β raw | |
| VERDICT.md | β | 6.4 KB | β raw | |
| verdict.json | β | 5.0 KB | view raw |
DF-2687 VERDICT β REPRODUCED (deterministic unprivileged kernel UAF, panic)
Bottom line
An unprivileged local user can leave a struct tty's t_session pointer
dangling into freed struct session memory and then force the kernel to
dereference it, with full control of the trigger timing. Demonstrated as a
deterministic kernel panic (lwkt_gettoken GP-fault on
0xdeadc0dedeadc0de after enabling the INVARIANTS free-poison
debug.use_weird_array) and as a raw dangling kernel heap pointer in the
kern.ttys sysctl dump. A one-hunk fix in sys/kern/tty.c (dissociate the
replaced tty in TIOCSCTTY) was built, installed, and verified to eliminate
both the panic and the dangling pointer.
The bug, line by line
ttioctl()TIOCSCTTY guard β sys/kern/tty.c:1183-1185:((p->p_session->s_ttyvp || tp->t_session) && (tp->t_session != p->p_session))β the only thing preventing a session from re-assigning its controlling tty is a non-NULLs_ttyvp(ortp->t_sessionequal to ours).- The vnode layer records the ioctl fd's vnode as
s_ttyvp(sys/vfs/devfs/devfs_vnops.c:1598-1611, sys/kern/vfs_vnops.c:1030-1052) with no validation that the fd is a terminal slave. A pty master fd is accepted. - Last close of that master vnode runs devfs' controlling-tty half-close
s_ttyvp = NULL(sys/vfs/devfs/devfs_vnops.c:1153-1156) and, because the ptm device's last close isptcclose(),ttyclose()is never called (sys/kern/tty_pty.c:636-689 has nottyclose), sotp->t_sessionsurvives whiles_ttyvpis NULL. - The session leader now passes the guard in (1) and TIOCSCTTYs a second
tty: sys/kern/tty.c:1190-1203 β
ttyhold(tp2),tp2->t_session = sp,otp = s_ttyp; s_ttyp = tp2; ttyunhold(otp).otp->t_sessionis never cleared β unlike every others_ttyptransition, which is paired withttyclearsession()(tty.c:279-316). - Leader exits:
sess_rele()repairs onlysess->s_ttyp->t_session(sys/kern/kern_proc.c:930-943) andkfree()s the session (kern_proc.c:947). The orphaned tty'st_sessionnow dangles. - Dereference sites (unprivileged reachability on the stock guest):
* final slave close β
ptscloseβttyclose(tty.c:261) βttyclearsession:sp = tp->t_session(tty.c:294),prg = sp->s_prg(tty.c:295),lwkt_gettoken(&prg->proc_token)(tty.c:296) β deterministic, this is the panic we hit. * master carrier loss βptccloseβttymodem:tp->t_session->s_leader+ksignal()(tty.c:1623-1624) β reachable on configurations where the master can be re-opened (e.g. a second devfs mount, jails); not reachable on the stock guest because unix98 master nodes are devfs-invisible after device close. *TIOCGSID/isctty()(tty.c:1009-1015, sys/sys/tty.h:216-217) β safe NULL-wise (compares against the caller's live session) but becomes a session-aliasing oracle when the freed chunk is recycled into a new session: the orphaned tty starts acting as the new session's controlling terminal (ctty hijack, cross-sessionTIOCSPGRP/TIOCSWINSZβpgsignalreach).
Reproduction evidence
panic.txtβ Fatal trap 9,Stopped at lwkt_gettoken+0x64: movq (%r12),%rax, current process = the unprivileged PoC (pid 2179), guest wedged atdb>.forensic.baseline.txtβ kern.ttys dump during PoC hold mode: orphaned TS_ZOMBIE pty (state=01100020, refs=0) withsession=0xfffff80116d4e970while its session is already freed (leader reaped) β the dangling pointer itself.run.logβ full run transcript (build + 40 pre-fix rounds + poison run leading to the panic).- Trigger chain:
df2687.csteps (1)-(6), all unprivileged: open ptmx/pts β fork L β L: setsid, TIOCSCTTY(master fd), close master, TIOCSCTTY(pts2), exit β parent closes orphaned slave β UAF.
Determinism: every code path is sequenced by the program; the only heap
dependency is what occupies the freed 104-byte chunk, which the
debug.use_weird_array poison fixes at 0xdeadc0de (a kernel INVARIANTS
facility provided precisely for exposing use-after-frees). With the flag
off the dereference still occurs β it reads stale freed-memory contents.
Exploit chain (assessed, not completed to uid=0)
The panic is proof of the primitive. Toward escalation on this bug:
groom M_SESSION chunks (setsid churn is same-type/same-size, LIFO per
CPU) so the freed session is recycled into an attacker-influenced session
(verified conceptually; the TIOCGSID alias oracle needs P_CONTROLT which a
candidate can set via its own TIOCSCTTY). s_leader then points at a
chosen live process β ttymodem() delivers SIGHUP to it; conversely, in
allocator states where the chunk holds non-session data, ksignal() is
called on a raw heap-content pointer. DF's slab allocator is per-CPU,
per-size-class and shared across malloc types, so cross-type grooming of
the 128-byte class is possible given CPU affinity. Full uid=0 was NOT
developed: on the stock guest the ttymodem sink is gated by devfs node
invisibility of closed clone masters, and ttyclearsession's s_prg
read is the only deterministic deref. The impact demonstrated here is
therefore: reliable local DoS (panic) + permanent kernel state corruption
(dangling pointer, leaked pgrp) + ctty hijack of recycled sessions.
Fix validation
fix.diffhunk 1 (tty.c) applied alone inside the guest (patch -p1),make -j6 nativekernel KERNCONF=X86_64_GENERIC(BUILD_RC=0, 357 s),make installkernel, reboot intoDragonFly 6.5-DEVELOPMENT #1: Sun Aug 30 20:17:44 UTC 2026.- Baseline (stock #0 kernel, same PoC,
debug.use_weird_array=1): Fatal trap 9 panic β reproduced. - Patched (#1 kernel):
/tmp/df2687 3β all rounds complete, POC_RC=0, guest up β no panic. - Patched forensic (hold mode):
state=01100020 dev=0x2100 pgrp=0x0 session=0x0β the orphaned tty is now fully dissociated (both the danglingt_sessionand the leakedt_pgrpare gone).
Environmental notes
- Guest: DragonFly 6.5-DEVELOPMENT, stock INVARIANTS kernel #0 (Thu Jul 2 06:02:54 UTC 2026), x86_64, 6 vCPUs, single devfs mount, no vfs.usermount.
- unix98 ptys only (
/dev/ptmxclone); no legacy/dev/pty??devices. debug.use_weird_arrayis 0 by default; enabling it does not change control flow of the bug β it poisons freed slab memory (first 64 bytes,0xdeadc0de) so the UAF read faults instead of silently consuming stale data.
Fix for DF-2687: orphaned tp->t_session UAF via TIOCSCTTY reassignment
Root cause (two cooperating defects)
-
sys/kern/tty.c ttioctl() TIOCSCTTY moves the session's s_ttyp to the new tty and drops the old tty's hold (ttyunhold) but never clears the old tty's t_session / t_pgrp. Every other s_ttyp transition is paired with a full dissociation (ttyclearsession); this one is not.
-
The vnode layer (sys/kern/vfs_vnops.c / sys/vfs/devfs/devfs_vnops.c) records the ioctl fd's vnode as s_ttyvp without validating that it is a terminal slave. Issuing TIOCSCTTY on a pty MASTER fd therefore makes the master vnode the session's ctty vnode; the master's last close then clears s_ttyvp (devfs_spec_close controlling-tty half-close) without running ttyclose() (ptcclose never calls it), leaving the session reassignable while the first tty's t_session still points at it.
After the session is freed (sess_rele only repairs sess->s_ttyp->t_session), the first tty's t_session dangles into freed M_SESSION memory and is dereferenced by ttyclearsession() (tp->t_session->s_prg -> lwkt_gettoken) on the final slave close and by ttymodem() (tp->t_session->s_leader -> ksignal) on master carrier loss.
Patch
- tty.c: dissociate the replaced tty inside TIOCSCTTY (clear t_session and t_pgrp under its token, release the pgrp reference) before ttyunhold().
- devfs_vnops.c / vfs_vnops.c: reject TIOCSCTTY on D_MASTER devices.
--- a/sys/kern/tty.c
+++ b/sys/kern/tty.c
@@ -1193,6 +1193,22 @@ case TIOCSCTTY: /* become controlling tty */
ttyhold(tp);
tp->t_session = p->p_session;
opgrp = tp->t_pgrp;
pgref(p->p_pgrp);
tp->t_pgrp = p->p_pgrp;
otp = p->p_session->s_ttyp;
p->p_session->s_ttyp = tp;
p->p_flags |= P_CONTROLT;
if (otp) {
+ /*
+ * Fully dissociate the tty we are replacing,
+ * otherwise its t_session pointer is left
+ * dangling into the (possibly soon-freed)
+ * session [DF-2687].
+ */
+ if (otp != tp) {
+ lwkt_gettoken(&otp->t_token);
+ if (otp->t_session == p->p_session) {
+ otp->t_session = NULL;
+ opgrp = otp->t_pgrp;
+ otp->t_pgrp = NULL;
+ if (opgrp) {
+ pgrel(opgrp);
+ opgrp = NULL;
+ }
+ }
+ lwkt_reltoken(&otp->t_token);
+ }
ttyunhold(otp);
}
if (opgrp) {
See fix.diff in this directory for the git-apply-able version including the devfs/vnops master-fd rejection.
Fix verification
fixedKernel rebuilt with fix.diff hunk 1 (sys/kern/tty.c TIOCSCTTY dissociation) only; exact same PoC with debug.use_weird_array=1 completes all rounds with no panic; kern.ttys forensic shows the orphaned tty fully dissociated (session=0x0, pgrp=0x0) where the stock kernel showed the dangling session pointer and panicked.
run.fix.log; forensic.fixed.txt; build.log (nativekernel BUILD_RC=0, installkernel INSTALL_RC=0)
Confirmed kernel references
Detail
Exploit chain
unprivileged setsid+ptmx -> TIOCSCTTY(master fd) -> close(master) clears s_ttyvp w/o ttyclose -> TIOCSCTTY(pts2) orphans tp1 (t_session kept, s_ttyp moved, ttyunhold) -> leader exit kfrees session -> tp1->t_session dangles -> close(orphan slave) -> ttyclose->ttyclearsession->s_prg->lwkt_gettoken on freed memory -> Fatal trap 9 panic; escalation ceiling (not completed): M_SESSION chunk recycle makes tp1->t_session alias a chosen live session (ctty hijack, cross-session TIOCSPGRP/TIOCSWINSZ pgsignal, ttymodem ksignal(s_leader) where master reopen is possible)
Evidence (decisive lines)
["panic.txt: 'Fatal trap 9 ... Stopped at lwkt_gettoken+0x64: movq (%r12),%rax' with current process = unprivileged df2687", 'forensic.baseline.txt: orphaned tty state=01100020 refs=0 session=0xfffff80116d4e970 (non-NULL dangling) in kern.ttys while the session is freed', 'forensic.fixed.txt: same orphan state after fix -> pgrp=0x0 session=0x0', 'run.log / run.fix.log: baseline panic vs patched 3 clean rounds with debug.use_weird_array=1', 'build.log: nativekernel BUILD_RC=0 (357s) + installkernel for the fix validation']
PoC changes
Replaced TIOCGPTN (EAGAIN-gated before slave open on DF) with fstat(st_rdev) minor unit discovery; fixed pipe end mixup; kept slave fd open in parent across leader exit (prevents VOP_CLOSE from running ttyclose early); added hold-mode for kern.ttys forensics; final trigger is the slave close (ttyclearsession sink) after enabling debug.use_weird_array poison; session-alias candidate swarm retained for the TIOCGSID oracle.
Verified recommended fix
TIOCSCTTY: fully dissociate the replaced tty (clear t_session/t_pgrp under its token, pgrel) before ttyunhold(); reject TIOCSCTTY on D_MASTER fds in devfs_fo_ioctl/vn_ioctl before the device ioctl runs (fix.diff)
Verdict
Deterministic unprivileged kernel use-after-free reproduced on the stock INVARIANTS guest: TIOCSCTTY on a pty MASTER fd makes the master vnode the session's ctty vnode; its last close clears s_ttyvp (devfs half-close) without ttyclose(), so tp->t_session survives; the leader then re-TIOCSCTTYs a second tty and exits; sess_rele() frees the session leaving the first tty's t_session dangling (proven non-NULL in kern.ttys for a TS_ZOMBIE orphan with refs=0); closing the orphaned slave dereferences sp->s_prg in ttyclearsession() and lwkt_gettoken() GP-faults on the INVARIANTS poison 0xdeadc0de (Fatal trap 9, lwkt_gettoken+0x64, pid of unprivileged PoC). Without the poison flag the same deref silently consumes freed-memory contents (ksignal-through-freed-s_leader reachable in configurations where the master can be re-opened). fix.diff hunk 1 (dissociate replaced tty in TIOCSCTTY) built, installed and verified: no panic and kern.ttys shows session=0x0/pgrp=0x0 for the orphan.
No comments yet.