ssh/tailssh: pty master dup'd without CLOEXEC leaks into every later SSH session's child; keeps dead ttys alive and hangs tmux
What is the issue?
ssh/tailssh/incubator.go duplicates the pty master with a raw dup(2):
// ssh/tailssh/incubator.go:935 (v1.102.4; same on main)
// We need to be able to close stdin and stdout separately later so make a dup.
ptyDup, err := syscall.Dup(int(pty.Fd()))syscall.Dup never sets FD_CLOEXEC (Go's own comment in syscall/exec_unix.go: "Dup. Use F_DUPFD_CLOEXEC or dup3 if available"). The original master from creack/pty is fine (os.OpenFile adds O_CLOEXEC), but ptyDup stays open in tailscaled until the stdout copier closes it at session end. Every cmd.Start() for any other SSH session during that window inherits ptyDup, because os/exec relies on CLOEXEC to drop fds. The fd then survives be-child → login -f → the user's shell → everything the shell runs.
Result: each new SSH session's process tree holds a pty master for every session that was alive when it started.
Consequence
When session A disconnects, tailscaled closes its own copies of A's master, but session B's shell (and children) still hold a copy. The kernel never hangs up A's slave: no SIGHUP to A's processes, and nobody ever reads A's master. Any process that writes to A's tty blocks forever once the tty buffer fills. tmux is single-threaded, so a tmux server writing its detach sequence to A's dead client tty hangs and every tmux command on the host hangs with it. Recovery requires finding and killing (or pidfd_getfd-draining) the unrelated process that holds the leaked master.
Evidence
Host: Ubuntu, kernel 7.0.0-1011-gcp, tailscale 1.102.4 (commit 3caf7d9e7d, go1.26.6), Tailscale SSH enabled, default tailscaled flags.
tailscaled holds two masters per session; the dup lacks CLOEXEC:
$ for n in /proc/$(pidof tailscaled)/fd/*; do [ "$(readlink $n)" = /dev/ptmx ] && grep -H -E 'flags|tty-index' ${n/fd/fdinfo}; done
fd22 flags 02100002 tty-index 6 # O_CLOEXEC set (pty.Open)
fd27 flags 0100002 tty-index 6 # NO O_CLOEXEC (syscall.Dup)
fd47 flags 02100002 tty-index 12
fd50 flags 0100002 tty-index 12Children of tailscaled (login -f and descendants) hold masters of every earlier session, at the same fd numbers tailscaled uses:
pid comm own tty ptmx masters held (fd:pts)
473998 login pts/6 (none) # first session
481460 login pts/10 fd27:pts6 fd31:pts8 fd35:pts9
482074 login pts/11 fd27:pts6 fd31:pts8 fd35:pts9 fd36:pts10
482456 login pts/12 fd27:pts6 fd31:pts8 fd35:pts9 fd36:pts10 fd46:pts11pts/8 and pts/9 had no living session at that point (fuser /dev/pts/8 empty); their masters existed only as leaked copies. Control group (tmux server panes, systemd --user, cron, a VS Code tunnel agent) held zero foreign masters.
tmux server stuck:
$ cat /proc/$(pgrep -x 'tmux: server')/stack | head -3
[<0>] wait_woken+0x7f/0x90
[<0>] n_tty_write+0x2f4/0x3f0
[<0>] iterate_tty_write+0x10e/0x260
$ strace -p $(pgrep -x 'tmux: server')
write(9, "\33[?1049l\33[23;0;0t", 17 # fd 9 -> /dev/pts/1, a client tty from a disconnected sessionSteps to reproduce
- Enable Tailscale SSH on a Linux host. Open SSH session A with a tty; run
tmux new -s t. - Open SSH session B with a tty (any shell).
ls -la /proc/$$/fd | grep ptmxinside B shows A's master. - Kill A's connection (drop network, or close the client without detaching tmux).
- In B:
tmux lshangs.cat /proc/$(pgrep -x 'tmux: server')/stackshowsn_tty_write. - In B:
exec trueor otherwise close the leaked fd (or exit B) → tmux server unblocks.
Suggested fix
Replace the raw dup with a CLOEXEC-safe dup:
ptyDup, err := unix.FcntlInt(pty.Fd(), unix.F_DUPFD_CLOEXEC, 0)(or unix.Dup3(oldfd, newfd, unix.O_CLOEXEC); syscall.CloseOnExec(ptyDup) after Dup is racy against concurrent ForkExec unless taken under syscall.ForkLock).
Related
- #21150 / #21166 address tailscaled keeping its own master open after disconnect. That change does not help here: the copies live in other sessions' children, which tailscaled cannot close.
- #4992 / #5000: same class of bug (non-CLOEXEC fd leaking into SSH children), previously for
/dev/net/tun.
Source: tailscale/tailscale