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
tailscale≥ v1.98.9 (the resolve-then-dial pattern was introduced by the TS-2026-004 fix), including current stable v1.102.4 (commit3caf7d9e7dcaba589cfc58beda596929733e4fea) andmainas of 2026-10-24.github.com/tailscale/glidersshv0.3.4-0.20260716005906-1a0f895faf28 —streamlocal.go,NewLocalUnixForwardingCallback/validateAndResolveSocketPath.- headscale tailnets are equally affected (all relevant code is client-side); verified
against headscale v0.29.5, which compiles accept actions with
AllowLocalPortForwarding: true.
Preconditions
- Attacker is on the tailnet and authorized to open a Tailscale SSH session to the target
host as a non-root local user —
autogroup:nonrootis the canonical example (same precondition as TS-2026-006 / TS-2026-009). - 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 totrue; please confirm the SaaS compiler. tailscaledruns as root (the normal systemd deployment).- 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):
- Create a legitimate socket at an allowed path:
socat unix-listen:/tmp/fwd,fork /dev/null— validation passes trivially (EvalSymlinks("/tmp/fwd") == "/tmp/fwd"). - Continuously and atomically alternate
/tmp/fwdbetween 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/fwdin a tight loop;rename(2)is atomic), while the SSH client repeatedly opensdirect-streamlocal@openssh.comchannels to/tmp/fwd. - 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. 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:
- 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-tcpipissue (report 2). - Linux-specific atomic variant:
open(path, O_PATH|O_NOFOLLOW)→fstat(assertS_ISSOCK, expected owner) →connectvia/proc/self/fd/N. No path re-resolution happens at connect time, eliminating the final-component race. - At minimum, drop
/tmpfromUserSocketDirectoriesand 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.