β¬’ 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 Β· 17 files
FileTypeDescriptionSize
README.md β€” 3.4 KB ↓ raw
VERDICT.md β€” 6.6 KB ↓ raw
FILM_SCRIPT.md β€” 6.2 KB ↓ raw
pty_master_hijack.c β€” 5.9 KB view raw
rules β€” 20 B ↓ download
build.sh β€” 113 B view raw
run.sh β€” 453 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
film_victim.c β€” 4.3 KB view raw
film_attack.c β€” 3.6 KB view raw
film_churn.c β€” 606 B view raw
film_run.sh β€” 5.0 KB view raw
run_film.log β€” 6.7 KB view raw
serial_console_film.log β€” 2.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.

Film take (Oct 16 2026 β€” stock kernel, verify/maintenance run)

Continuous two-actor disclosure demo on the stock clean-install kernel (DragonFly v6.5.0.1712.g89e6a-DEVELOPMENT #1), one untrimmed session 11:40:40–11:40:57 UTC:

  • run_film.log β€” the authoritative untrimmed transcript (9 beats): root session shown ALIVE and INTERACTIVE (whoamiβ†’root), terminal crash, ps proof the uid-0 session leader survives, nobody probe FENCED (ENOENT, default devfs), admin show ptm rules applied (after a churn/login modeled by film_churn), stale master visible crw-rw-rw- root root, then the takeover: master opened, TS_ZOMBIE cleared via TIOCSETAW+CLOCAL, payload id > /tmp/df2769_pwned; cat /etc/master.passwd > /tmp/df2769_secret injected, whoami; hostname round-trip shows root/dfbsd answering on the attacker's screen, 0:0-owned marker written, RESULT=HIJACKED; proofs: all three files root:wheel-owned, uid=0(root) content, master.passwd hash exfil'd. Attacker re-checked as nobody after the takeover.
  • serial_console_film.log β€” the kernel's own serial console recorded the decisive arc (BEAT 5 takeover through FILM END), including the root shell's answers to nobody. (Console drops lines under burst; earlier beats live only in run_film.log.)
  • FILM_SCRIPT.md β€” beat-by-beat re-recording guide + the two operational gotchas discovered while hardening the take: (a) a failed open("/dev/ptm/N") leaves a negative namecache entry that devfsctl -a does NOT invalidate (rule apply never bumps mnt_namecache_gen; only devfs node creation does β€” sys/vfs/devfs/devfs_core.c:277), so the demo churns once (any pty allocation, i.e. any login) between the fenced probe and the rule application; (b) devfsctl -r (node reset) must never be used β€” it breaks later show-rule application for the rest of the boot. Neither is a new kernel finding; both are demo-environment behavior of the existing namecache/devfs machinery.
  • Fix-contrast was skipped for this take: the stock snapshot ships no /usr/src, so the patched kernel needs a full source+kernel rebuild (out of scope per mission). The Sep 1 fix validation stands unchanged: patched kernel refuses the stale master open with EIO, no injection, no proof file.

New artifacts: film_victim.c, film_attack.c, film_churn.c, film_run.sh, run_film.log, serial_console_film.log, FILM_SCRIPT.md. The original verified rig (pty_master_hijack.c / run.log) is untouched.

Kernel references

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.

FILM_SCRIPT.md
↓ download raw

DF-2769 β€” FILM SCRIPT (disclosure-video demo run)

One continuous take, two actors, stock kernel. Deliverables: run_film.log (untrimmed driver transcript, the authoritative record), serial_console_film.log (what the kernel's own serial console recorded of the decisive arc), film_run.sh (the take driver), film_victim.c / film_attack.c / film_churn.c (the actors).

  • Stock kernel: DragonFly v6.5.0.1712.g89e6a-DEVELOPMENT #1 (clean-install snapshot, no custom build, INVARIANTS off).
  • Root appears ONLY as the victim. The attacker is nobody (uid 65534) the whole way through; id is re-checked after the takeover.
  • Wall time of the take: 11:40:40 β†’ 11:40:57 UTC (see timestamps in run_film.log).

Cast / terminals (for a human re-recording)

Terminal Who Content
VICTIM window root admin session inside a modeled terminal (xterm / ssh -tt / script(1)) live root@dfbsd:~ # shell doing admin things β€” then the terminal dies
ATTACKER window nobody discovers the stale master, takes the session over, talks to the root shell
NARRATOR (optional) the demo driver console beat banners + proofs; on camera this is the voiceover

The automated take drives all three from one script (film_run.sh), teeing everything into run_film.log and /dev/console (serial line). For a two-terminal re-recording, run the beats below manually in the same order β€” every command is in the log.

Beat sheet (timestamps from run_film.log)

BEAT 0 β€” SETUP [11:40:40] β€” stage: kernel line, narrator uid=0, attacker will be nobody (uid shown up front so nobody can accuse a swap), proof files pre-checked absent, the two actors compiled on camera.

BEAT 1 β€” VICTIM LIVE [11:40:40–:45] β€” a root admin session opens on /dev/pts/0 (pid 867) inside its terminal. On the VICTIM screen the shell is alive and interactive: whoami β†’ root, hostname β†’ dfbsd, id β†’ uid=0(root)…, echo ROOT-SESSION-ALIVE. This is the session we will own.

BEAT 2 β€” TERMINAL CRASH [11:40:45] β€” *** TERMINAL CRASH *** (xterm killed / ssh connection dropped): the master closes. Kernel fact box on screen: ptcclose() disarms every ptcopen() gate, leaves /dev/ptm/N registered 0666 root:root, tty goes ZOMBIE, SIGHUP is ignored by the nohup-style session leader. ps proof: pid 867, UID 0, still running; slave device /dev/pts/0 still present. A root shell now waits silently on a dead pty whose master seat is empty.

BEAT 3 β€” ATTACKER PROBE, DEFAULT DEVFS [11:40:45] β€” ATTACKER window: su -m nobody -c "/tmp/film/film_attack /dev/pts/0" β†’ open("/dev/ptm/0") -> No such file or directory, RESULT=FENCED. Honesty beat: on a stock devfs ruleset /dev/ptm is DEVFS_HIDDEN; ls /dev/ptm* shows only /dev/ptmx. The bug needs a config precondition (next beat).

BEAT 4 β€” CONFIG PRECONDITION [11:40:45] β€” the system churns (any login allocates a pty β€” film_churn), then the host's admin rules are (re)applied: show ptm + show ptm/* via devfsctl -a β€” exactly the rule class jail devfs rulesets and expose-/dev recipes install. ATTACKER view flips: ls -l /dev/ptm* now shows /dev/ptm/0 crw-rw-rw- root root β€” the dead terminal's seat, world-openable.

BEAT 5 β€” TAKEOVER, INJECTION, LIVE CONTROL [11:40:47–:57] β€” same unprivileged command as BEAT 3, different world now: 1. open("/dev/ptm/0", O_RDWR) β†’ master fd opened β€” nobody is the new master of a live root session; 2. TIOCSETAW(c_cflag |= CLOCAL) β†’ TS_ZOMBIE cleared (the kernel's last fence, cleared by the attacker itself, sys/kern/tty.c:1065-1077); 3. INJECT id > /tmp/df2769_pwned; cat /etc/master.passwd > /tmp/df2769_secret β€” the ATTACKER screen shows the root prompt accepting the payload; 4. INJECT whoami; hostname β€” the ATTACKER screen shows the root shell answering root / dfbsd live (the money shot); 5. INJECT echo DF-2769-PWNED > /tmp/df2769_marker β€” a 0:0-owned marker; RESULT=HIJACKED.

BEAT 8 β€” PROOF [11:40:57] β€” attacker still nobody; the three files created by the root session for the attacker: all root:wheel-owned; /tmp/df2769_pwned contains uid=0(root); /tmp/df2769_secret is /etc/master.passwd (root's password hash on screen, truncated); marker owner=root:wheel.

BEAT 9 β€” CLEANUP [11:40:57] β€” victim session terminated, proof files removed, devfs rules cleared. FILM END.

Re-recording gotchas (learned the hard way β€” keep this order)

  1. Never run devfsctl -r (node reset) on a live system in this demo: it breaks later show rule application for the boot. Clear rules with devfsctl -c only; nodes re-hide at next boot.
  2. A failed open("/dev/ptm/N") caches a negative namecache entry that survives the subsequent devfsctl -a (rule apply doesn't bump devfs's mnt_namecache_gen; only node creation does, sys/vfs/devfs/devfs_core.c:277). Between the fenced probe (BEAT 3) and the rule application (BEAT 4) you must let the system churn once β€” any pty allocation/free (film_churn, i.e. what any login does) β€” otherwise ls/open of /dev/ptm keep returning ENOENT for up to the life of that entry. This is why the driver runs film_churn before devfsctl -a.
  3. Churn after the rules would re-hide /dev/ptm (creation-time ptm magic, devfs_core.c:2125-2133) β€” always churn then apply rules.
  4. The victim session leader must ignore SIGHUP (nohup semantics) and tolerate tty EOF to survive the terminal death β€” that is the surviving-session class this finding is about (crashed xterm / dropped ssh with a nohup'd leader).
  5. Serial console (/dev/console) drops lines under burst; it caught the whole decisive arc (BEAT 5β†’END) in serial_console_film.log, but the authoritative untrimmed transcript is run_film.log.

Fix-contrast (not in this take)

The stock clean-install snapshot ships no /usr/src; producing the patched kernel means a full source + kernel build, which the mission says to skip. Prior fix validation stands unchanged: on the fix.diff kernel (VERDICT.md "Fix validation"), with identical setup and devfs exposure, the stale master open fails with EIO (PF_SOPEN fence), no injection, no proof file.

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.