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

Stale unix98 pty master re-open admitted by ptcopen (ptcclose disarms all gates): cross-user session takeover with root command injection when /dev/ptm is exposed by devfs rules

Field Value
ID DF-2769
Status new
Severity High
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H
CWE CWE-668 / CWE-284
File sys/kern/tty_pty.c
Lines 674-682 (ptcclose disarm), 585-592 (ptcopen no fence), 273
Area kern
Confidence certain
Discovered 2026-08-30
Pass 2 (GLM 5.3 second pass)
Bucket privesc
Reported pending
Known CVE none
CVE match novel

Summary

When the legitimate master closes while the slave is still open (crashed xterm, dead tmux/screen server, killed script(1), nohup'd session leader), ptcclose clears t_oproc, NULLs pt_prison, and sets both device nodes to 0:0 mode 0666 β€” disarming every admission gate ptcopen checks β€” while termination (and destruction of the master cdev /dev/ptm/N) is blocked by PF_SOPEN. ptcopen then admits ANY local user as the new master of the still-live slave session. The TS_ZOMBIE fence set by ttymodem(0) is cleared by the new master itself via TIOCSETAW+CLOCAL. /dev/ptm is DEVFS_HIDDEN on the default ruleset (control run FENCED); exposure requires a devfs 'show' rule applied after node creation β€” exactly what jail devfs rulesets and expose-/dev recipes produce.

Threat model & preconditions

On systems/jails whose devfs rules expose /dev/ptm, any local user hijacks any still-open pty session whose master died: command injection as the session owner (root sessions β†’ uid 0, demonstrated), terminal output capture (passwords/secrets), arbitrary signal injection (TIOCSIG), and device re-chown lockout. Unix98 semantics elsewhere (Linux, FreeBSD pts) make the pty dead for everyone after master close; DFly's deviation is the defect.

Proof of contest

Live-proofed end-to-end on the guest (findings/poc/DF-2769/ pty_master_hijack.c): victim = root-owned pty session surviving master death (SIGHUP ignored); attacker = nobody opened the stale master, cleared ZOMBIE, injected id > /tmp/df2769_root_pwn which the root session executed (root-owned file, uid=0(root)), and read the victim's terminal output. Default-config control FENCED; patched kernel (PF_SOPEN fence in ptcopen β†’ EIO) blocks the attack with fresh pty allocation unaffected.

Unix98 semantics β€” the master cannot be re-acquired once closed while the slave is open: add the PF_SOPEN fence in ptcopen (validated diff in findings/poc/DF-2769/fix.diff).

Timeline

  • 2026-08-30 Discovered during pass-2 audit of tty_pty.c (GLM 5.3); full root-command-injection chain demonstrated under devfs exposure
  • fix validated same run.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2769 Β· 10 files
FileTypeDescriptionSize
README.md β€” 3.4 KB ↓ raw
VERDICT.md β€” 3.8 KB ↓ raw
pty_master_hijack.c β€” 5.9 KB view raw
rules β€” 20 B ↓ download
build.sh β€” 131 B view raw
run.sh β€” 875 B view raw
run.log β€” 1.6 KB view raw
run.fix.log β€” 884 B view raw
env.txt β€” 289 B view raw
fix.diff β€” 1.9 KB view raw

DF-2769 β€” stale unix98 pty master re-open = session takeover (root command injection)

What it is

When the legitimate master of a unix98 pty closes while the slave side is still open (crashed xterm, dead tmux/screen server, killed script, nohup'd session leader), ptcclose() (sys/kern/tty_pty.c:637-689)

  • clears t_oproc (:674) β€” disarming ptcopen's only "already has a master" gate (tty_pty.c:587),
  • NULLs pt_prison (:676) β€” disarming ptcopen's jail isolation gate (tty_pty.c:580),
  • sets both device nodes to uid 0 gid 0 mode 0666 (:677-682) β€” world openable,

while the pty is not terminated (termination requires !PF_SOPEN, tty_pty.c:273), so the master cdev /dev/ptm/N stays registered. ptcopen() then admits any local user as the new master of the still-live slave session. The TS_ZOMBIE fence set by ttymodem(0) (tty.c:1616-1630) is cleared by the new master itself: ptyioctl's master switch intercepts TIOCSETA* only to flush the outq and falls through to ttioctl, whose TIOCSETA/SETAW/SETAF handler clears TS_ZOMBIE when CLOCAL gets set (sys/kern/tty.c:1065-1072, 1077).

The new master then has, against the surviving session:

  • input injection β€” ptcwrite() β†’ l_rint β†’ the victim's tty reads (the victim's shell executes it),
  • output capture β€” ptcread() drains t_outq,
  • signal injection β€” TIOCSIG β†’ pgsignal(t_pgrp, sig, 1) with any signal < NSIG,
  • device re-chown: the pty is re-owned to the attacker (tty_pty.c:616-621).

Unix98 semantics elsewhere (Linux, FreeBSD pts): after the master closes the pty is dead for everyone (slave gets EIO/SIGHUP, no new master can ever attach). DFly's master-cdev persistence + permissive ptcopen is the deviation.

Precondition (why this is not the default config)

/dev/ptm/* nodes are created DEVFS_HIDDEN (sys/vfs/devfs/devfs_core.c:2125-2133) and devfs_spec_open refuses hidden nodes (ENOENT). Exposure requires a devfs show rule covering ptm and its children β€” exactly what jail devfs rulesets / "expose /dev" recipes produce, applied any time after the node exists (the hide is re-applied at each ptm-node creation, devfs_core.c:2130, so rules applied at creation time lose; rules applied later β€” e.g. an admin devfsctl -a pass or a jail mount with a show-all ruleset β€” win). The PoC uses devfsctl as root to load show ptm + show ptm/*, modeling such a configuration; the kernel-side chain needs no privilege.

Reproduce

cc -O2 -Wall -o pty_master_hijack pty_master_hijack.c

# default config control (fenced):
./pty_master_hijack victim &           # prints UNIT=/dev/pts/N, closes master
./pty_master_hijack attack /dev/pts/N /tmp/cmd
# -> open /dev/ptm/N: No such file or directory(2), RESULT=FENCED

# with devfs rule exposing ptm (run.sh):
devfsctl -a -m /dev -f rules      # show ptm ; show ptm/*
./pty_master_hijack attack /dev/pts/N /tmp/cmd    # as ANY other user
# -> master opened, TS_ZOMBIE cleared via TIOCSETAW+CLOCAL,
#    command injected into the victim's tty; victim output readable

# decisive run: victim = ROOT-owned session, attacker = nobody,
# cmd = "id > /tmp/df2769_root_pwn"
# -> /tmp/df2769_root_pwn owned by root, content "uid=0(root) ..."

Fix

Refuse master re-open while the slave is still open (PF_SOPEN β†’ EIO in ptcopen). PF_SOPEN can only be set after a previous master existed, so first-open behavior is unchanged. See fix.diff.

VERDICT.md
↓ download raw

DF-2769 VERDICT

Status: reproduced β€” unprivileged (nobody) arbitrary command execution in a surviving ROOT pty session (uid=0 file created by the victim session), conditional on a devfs rule exposing /dev/ptm (fenced with ENOENT on the default ruleset).

Chain (all steps live-verified on stock INVARIANTS kernel #0)

  1. Victim (root): posix_openpt β†’ grantpt/unlockpt β†’ ptsname β†’ UNIT=/dev/pts/1; child: setsid + TIOCSCTTY, dup2 slave to stdio, signal(SIGHUP, SIG_IGN) (nohup semantics), loop reading tty lines and system()-ing them; parent closes the master fd at t+1s and exits β†’ models a crashed xterm/tmux-server with the session alive. After ptcclose: t_oproc=NULL (tty_pty.c:674), pt_prison=NULL (:676), devs/devc β†’ 0:0 mode 0666 (:677-682); termination blocked by PF_SOPEN (:273); ttymodem(0) set TS_ZOMBIE + SIGHUP (ignored).
  2. Control (default devfs): attacker open("/dev/ptm/1") β†’ ENOENT (DEVFS_HIDDEN, devfs_core.c:2125-2133 + devfs_spec_open accessibility check) β€” captured in run.log as RESULT=FENCED.
  3. Precondition (root, once): devfsctl -a -m /dev -f rules with show ptm + show ptm/* β€” /dev/ptm/1 becomes visible crw-rw-rw- root:wheel. This models jail/"expose /dev" rulesets; note the kernel re-hides at each new ptm node creation, so exposure occurs whenever rules are (re)applied after creation β€” e.g. any later devfsctl pass or a jail devfs mount with a showing ruleset.
  4. Attacker (nobody): open("/dev/ptm/1", O_RDWR) β†’ succeeds (ptcopen gates all disarmed). TIOCGETA + TIOCSETAW with c_cflag |= CLOCAL β†’ ttioctl tty.c:1065-1072 clears TS_ZOMBIE (log: "TS_ZOMBIE cleared via TIOCSETAW+CLOCAL").
  5. write(master, "id > /tmp/df2769_root_pwn\n") β†’ ptcwrite β†’ l_rint β†’ victim's fgets executes system("id > /tmp/df2769_root_pwn") as root; attacker read() the echoed line and "[victim rc=0]".
  6. Result: /tmp/df2769_root_pwn owned by root, content uid=0(root) gid=0(wheel) ....

Impact

With /dev/ptm exposed (jail rulesets, devfs.conf show rules), any local user hijacks any still-open pty session whose master died: command injection as the session owner (root sessions β†’ uid 0), terminal output capture (secrets/passwords typed in the session), arbitrary-signal injection (TIOCSIG), and device re-chown lockout. On the default devfs ruleset the by-name path is ENOENT-fenced; the kernel-side defect is unchanged.

Not-a-bug notes

  • Same-uid re-mastering is equally possible (weaker demo).
  • The reclaim of fully-closed-but-session-referenced ptys (t_refs>0 zombie units, DF-2687 state) is a related but distinct window; the PF_SOPEN fence in fix.diff deliberately leaves it untouched.

Fix validation

Combined patch (DF-2768+DF-2769 hunks) built (make -j6 nativekernel && make installkernel) and booted in the guest; re-running the attack (with the devfs rule re-applied) the stale master open then fails with EIO (fence hit) and no injection occurs; victim session stays ZOMBIE. See fix run logs.

Kernel references

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Patched kernel refuses the stale master open with EIO (PF_SOPEN fence); identical devfs exposure; no injection, no root-owned proof file; normal pty operation unaffected.

['run.fix.log', 'fix.diff', 'DF-2768/fix_build.log (shared combined-patch build, INSTALL-DONE)']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Tue Sep 1 00:17:25 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

victim session on pty (SIGHUP-ignored) outlives its master -> ptcclose disarms gates, /dev/ptm/N stays 0666 root:root -> attacker opens /dev/ptm/N (needs devfs show rule) -> TIOCGETA+TIOCSETAW c_cflag|=CLOCAL clears TS_ZOMBIE -> ptcwrite injects command lines the victim's shell executes as the session owner (root in the decisive run) -> ptcread captures victim output / TIOCSIG injects arbitrary signals.

Evidence (decisive lines)

['run.log: RESULT=FENCED control on default ruleset (ENOENT), then RULES_OK + ls showing /dev/ptm/1 crw-rw-rw- 0666, then attack: master opened / ZOMBIE cleared / injected / read[1] [victim rc=0] / RESULT=HIJACKED, then /tmp/df2769_root_pwn owned by root with content uid=0(root)', 'run.fix.log: identical setup on patched kernel -> open fails EIO (PF_SOPEN fence), no proof file', 'VERDICT.md: full chain + kernel refs']

PoC changes

n/a (authored fresh; victim deliberately uses SIGHUP-ignoring EOF-tolerant read loop to model nohup'd/crashed-terminal sessions; grantpt/unlockpt/ptsname all functional on this guest)

Verified recommended fix

In ptcopen(), refuse master opens while the slave side is still open (PF_SOPEN -> EIO): unix98 semantics make the pty dead for everyone once its master has closed.

Verdict

ptcclose() disarms every ptcopen() admission gate when the master closes while the slave is open (t_oproc=NULL at tty_pty.c:674, pt_prison=NULL at :676, nodes re-set to 0:0 mode 0666 at :677-682) and termination is blocked by PF_SOPEN (:273), leaving the master cdev /dev/ptm/N registered and world-openable; ptcopen() then admits ANY local user as the new master of the still-live slave session. The TS_ZOMBIE fence is cleared by the new master itself (TIOCSETAW + CLOCAL via ptyioctl fallthrough -> ttioctl, tty.c:1065-1072). Live-proofed end-to-end on the stock INVARIANTS kernel: victim = root-owned pty session that survives master death (SIGHUP ignored, models crashed xterm/tmux-server/nohup session leader); attacker = nobody opened the stale master, cleared ZOMBIE, injected 'id > /tmp/df2769_root_pwn' which the root session executed (file owned by root, content uid=0(root)), and read the victim's terminal output back. CONFIG PRECONDITION: /dev/ptm is DEVFS_HIDDEN by default (devfs_core.c:2125-2133) and the by-name attack is ENOENT-fenced on a stock ruleset (control run captured); exposure requires a devfs 'show' rule covering ptm and its children, applied any time after node creation - exactly what jail devfs rulesets / expose-/dev recipes produce (the kernel re-hides only at node creation). Kernel-side chain itself needs no privilege.