Report 1: TOCTOU in Tailscale SSH Unix-socket forwarding bypasses the TS-2026-004 fix (root access to privileged sockets)

2026/10/24

TOCTOU race in local Unix-socket forwarding

Severity (my assessment): High — full host compromise from an authorized non-root SSH session on hosts with privileged Unix sockets (docker, containerd, CRI). Date found: 2026-10-24 · Reporter: psn · shared privately with Tailscale 2026-10-24; tailworm.com (published 2026-10-25) independently confirms this class

Summary

The symlink protection added for TS-2026-004 (Tailscale SSH Unix socket forwarding, fixed in v1.98.9/v1.98.10) validates and dials the target socket path in two separate, non-atomic steps. An SSH-authorized non-root user on any host running tailscaled as root can win the window between validation and connect(2) with a symlink swap, causing the root-owned tailscaled process to connect to privileged Unix sockets the user cannot reach themselves (e.g. /var/run/docker.sock) — full host compromise via the forwarded socket.

Affected versions

Preconditions

  1. Attacker is on the tailnet and authorized to open a Tailscale SSH session to the target host as a non-root local user — autogroup:nonroot is the canonical example (same precondition as TS-2026-006 / TS-2026-009).
  2. The matched SSH action permits local port forwarding (AllowLocalPortForwarding), which also gates Unix forwarding (ssh/tailssh/tailssh.go, mayForwardLocalUnixTo). Accept actions compiled by headscale set this to true; please confirm the SaaS compiler.
  3. tailscaled runs as root (the normal systemd deployment).
  4. A privileged Unix socket exists on the host (docker, containerd, CRI — ubiquitous).

Technical detail

NewLocalUnixForwardingCallback (gliderssh streamlocal.go):

resolved, err := validateAndResolveSocketPath(socketPath, opts) // EvalSymlinks + allowlist re-check
if err != nil { return nil, err }
...
var d net.Dialer
return d.DialContext(ctx, "unix", resolved)                     // separate syscall, later

ssh/tailssh/tailssh.go wires the allowlist via unixForwardingOptions():

AllowedDirectories: gliderssh.UserSocketDirectories(c.localUser.HomeDir, c.localUser.Uid),
// => { $HOME, /tmp, /run/user/<uid> }

/tmp and $HOME are writable by the attacker’s own local account. The TS-2026-004 fix resolves symlinks and re-checks the resolved path — but that check is not atomic with the subsequent connect(2), and the kernel resolves the final symlink at connect time, not the path that was validated.

Attack walkthrough

Attacker uses two shells on the target host as themselves (two channels of the same authorized SSH session are enough):

  1. Create a legitimate socket at an allowed path: socat unix-listen:/tmp/fwd,fork /dev/null — validation passes trivially (EvalSymlinks("/tmp/fwd") == "/tmp/fwd").
  2. Continuously and atomically alternate /tmp/fwd between the real socket and a symlink to /var/run/docker.sock (ln -sf /var/run/docker.sock /tmp/fwd.new && mv -Tf /tmp/fwd.new /tmp/fwd in a tight loop; rename(2) is atomic), while the SSH client repeatedly opens direct-streamlocal@openssh.com channels to /tmp/fwd.
  3. Any attempt where validation observes the real socket and connect(2) observes the symlink yields a root-originated connection to /var/run/docker.sock, tunneled back to the attacker’s SSH client.
  4. docker run -v /:/host --privileged ... through the forward → root on the host.

Attempts are free, unthrottled, and parallelizable; with a kHz-rate rename loop and sustained forward requests, success is a matter of seconds. No SUID binaries, no kernel race — plain TOCTOU on a security control.

Secondary (lower severity) race, same family

The reverse/listen path (NewReverseUnixForwardingCallback; tailssh sets BindUnlink: true) has an analogous Lstat → unlink → bind sequence in validateAndResolveListenPath. The parent directory is resolved, but the unlink/bind pair re-references the path; impact appears limited to file creation/replacement as root within the resolved allowed directories. Included for completeness.

Suggested remediation

In rough order of preference:

  1. Privilege-separate the dial. Perform forwarded connections (Unix and TCP) from a process running as the session user’s uid, the way OpenSSH’s unprivileged child handles forwarding. Root-only sockets and ports then become unreachable by construction — this also fixes the companion direct-tcpip issue (report 2).
  2. Linux-specific atomic variant: open(path, O_PATH|O_NOFOLLOW) → fstat (assert S_ISSOCK, expected owner) → connect via /proc/self/fd/N. No path re-resolution happens at connect time, eliminating the final-component race.
  3. At minimum, drop /tmp from UserSocketDirectories and stop relying on validate-then-use for user-writable directories.

Detection

tailscaled logs forwarding channel setup but nothing that distinguishes a raced dial from a normal one (requested and dialed paths are the same string). Suggested: log requested path, validated path, and post-connect peer credentials for Unix dials; alert on mismatch or on connects to sockets outside allowed dirs.

Relation to public information

Erica Windisch disclosed on 2026-10-23 that she would publish a “tailscale privilege escalation” (post 3mykgeqrggk2o on bsky.app) with these public constraints: attacker must be on the tailnet; affected systems are in “a non-default but widely deployed and supported configuration”; impact includes headscale users; primarily enterprise. This finding matches every stated constraint, but her report is unpublished — no claim is made that this is the same bug.

Update 2026-10-25: her report is out — https://www.tailworm.com. Finding 3 (T2-RACE/T3-RACE) is this report’s bug, independently found: same validateAndResolve{Listen,Socket}Path check-to-use gap, same {home, /tmp, /run/user} allowlist, same rename-race exploitation — and taken much further (bind-side socket impersonation, /run/systemd/private root shell, authkey capture and rogue-node enrollment, root chmod/unlink primitives). Report 2’s remediation #1 (privilege-separate the dial) is also her recommended root-cause fix, with OpenSSH and post-CVE-2025-14282 Dropbear as the existence proofs.