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.
Recommended fix
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)
PoC verification
Evidence pack
findings/poc/DF-2769 Β· 10 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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()drainst_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.
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)
- 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 andsystem()-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). - 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.
- Precondition (root, once):
devfsctl -a -m /dev -f ruleswithshow 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. - 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").
- 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]".
- Result:
/tmp/df2769_root_pwnowned byroot, contentuid=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
- sys/kern/tty_pty.c:636-689 (ptcclose disarms all gates, 0666)
- sys/kern/tty_pty.c:560-634 (ptcopen admission: 571-573 si_drv1, 580-584 prison, 585-592 t_oproc β none fence the stale case)
- sys/kern/tty_pty.c:273-293 (termination gated on !PF_SOPEN)
- sys/kern/tty_pty.c:745-750 + 1049-1055 (TS_CONNECTED/EOF paths)
- sys/kern/tty_pty.c:1081-1275 (ptyioctl master fallthrough to ttioctl)
- sys/kern/tty.c:1060-1080 (TIOCSETA* CLOCAL clears TS_ZOMBIE)
- sys/kern/tty.c:1600-1650 (ttymodem ZOMBIE/SIGHUP on carrier loss)
- sys/vfs/devfs/devfs_core.c:2068-2133 (node creation + ptm hide)
- sys/vfs/devfs/devfs_core.c:2598-2603 + devfs_vnops.c:867-869 (DEVFS_HIDDEN β ENOENT fence)
- sys/vfs/devfs/devfs_rules.c:325-331 (show rule clears DEVFS_HIDDEN)
Fix verification
fixedPatched 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)']
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.
No comments yet.