#21343·tailscale

ssh/tailssh: pty master dup'd without CLOEXEC leaks into every later SSH session's child; keeps dead ttys alive and hangs tmux

Author: sachmaanCreated Sep 17, 2026Updated Sep 17, 2026
LabelsOS-linuxbugssh

What is the issue?

ssh/tailssh/incubator.go duplicates the pty master with a raw dup(2):

go
// 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-childlogin -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 12

Children 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:pts11

pts/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 session

Steps to reproduce

  1. Enable Tailscale SSH on a Linux host. Open SSH session A with a tty; run tmux new -s t.
  2. Open SSH session B with a tty (any shell). ls -la /proc/$$/fd | grep ptmx inside B shows A's master.
  3. Kill A's connection (drop network, or close the client without detaching tmux).
  4. In B: tmux ls hangs. cat /proc/$(pgrep -x 'tmux: server')/stack shows n_tty_write.
  5. In B: exec true or otherwise close the leaked fd (or exit B) → tmux server unblocks.

Suggested fix

Replace the raw dup with a CLOEXEC-safe dup:

go
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.