high

CVE

Not assigned

CWE

CWE-250, CWE-367, CWE-362, CWE-400, CWE-863

Affected Surface

  • Tailscale client daemon (`tailscaled` / `tailscale.com`) versions `< 1.104.4` running Tailscale SSH (`TS-2026-014`, `TS-2026-015`) or `tailscale serve` (`TS-2026-016`, `TS-2026-017`)
  • Self-hosted `headscale` control planes (`v0.29.5` and earlier), which unconditionally grant SSH port and Unix-socket forwarding on every accepted Tailscale SSH session and remain affected by unauthenticated Noise auth-cache eviction (`C4`) and embedded-DERP memory growth (`C2`)
  • Tailscale experimental `conn25` app connectors (including Kubernetes operator connector pods), where per-flow transit mappings (`Finding 4` / `C1`, tracked in `tailscale/tailscale#22003`) did not restrict client-supplied egress destinations against the app's configured domains

TL;DR

On 25 October 2026, Erica Windisch of Cyberdione Labs published Tailworm, an uncoordinated 11-finding security audit of tailscaled v1.104.2, its forked gliderssh library, and the self-hosted headscale v0.29.5 control server. On 27 and 28 October, Tailscale released v1.104.4 and issued four security bulletins (TS-2026-014, TS-2026-015, TS-2026-016, and TS-2026-017):

  1. Root Unix-socket forwarding and TOCTOU allowlist bypass in Tailscale SSH (TS-2026-014, High): Unlike OpenSSH, which drops privileges to the target local user before running the post-authentication forwarding loop, tailscaled ran direct-streamlocal (ssh -L) and streamlocal-forward (ssh -R) inside the root-privileged daemon and relied on a lexical allowlist (home, /tmp, /run/user) checked by validateAndResolveSocketPath(). Any tailnet peer permitted to open a non-root SSH session with forwarding enabled could dial root-only sockets in allowlisted directories (ssh -L), unlink root-owned sockets in sticky directories (ssh -R BindUnlink), or run a rename(2) symlink race inside the SSH session to defeat the lexical check and reach /run/docker.sock, /run/systemd/private (SO_PEERCRED uid=0), or /run/tailscale/tailscaled.sock in under a second per hop.
  2. Unbounded SSH session memory retention (TS-2026-015, High): Flooding an authenticated Tailscale SSH session with window-change (window-change) messages and environment variable appends parked goroutines and grew tailscaled RSS by ~536 MB in 7 seconds, persisting after the client disconnected until the Linux OOM killer terminated tailscaled and dropped all tailnet connectivity on the host.
  3. tailscale serve Unix-socket LocalAPI takeover and symlink swap (TS-2026-016, High / Medium): While tailscale serve HTTP proxying blocked tailscaled.sock via isTailscaledSocket(), the raw TCP forwarder (tailscale serve --bg --tcp unix:/run/tailscale/tailscaled.sock) had no check and dialed the LocalAPI socket as root, handing any ACL-authorized remote peer PermitWrite administrative control over the node. In addition, the HTTP proxy’s isTailscaledSocket() check ran only at config load time rather than per dial, allowing a post-config symlink swap to retarget requests at tailscaled.sock or /run/docker.sock.
  4. HTTP/2 Tailscale-App-Capabilities bleed across tailscale serve mounts (TS-2026-017, Medium): serveWebHandler stored per-mount AppCapabilities on a single connection-scoped serveHTTPContext struct shared across all multiplexed HTTP/2 streams on a TLS connection. Racing concurrent requests to an unprivileged mount and a restricted mount caused addAppCapabilitiesHeader() to forward the privileged Tailscale-App-Capabilities header to the wrong backend mount.

What to do now:

  • Upgrade all nodes running Tailscale SSH, tailscale serve, or DERP clients to v1.104.4 or newer (sudo tailscale update or package manager upgrade).
  • Where nodes cannot be upgraded immediately, set TS_SSH_DISABLE_FORWARDING=1 in /etc/default/tailscaled and restart tailscaled, which disables SSH TCP and Unix-socket forwarding without turning off Tailscale SSH shell access.
  • Audit your tailnet policy file: replace broad tag-to-tag or autogroup:member SSH rules with specific source-to-target grants, and note that Tailscale SSH action: check does not block TS-2026-014 once a session is established. On self-hosted headscale v0.29.5, forwarding cannot be disabled in policy and is granted on every SSH rule, so upgrade tailscaled on every target host first.

Affected packages and components

Bulletin / FindingComponentSeverityAffected versionsFixed versionVulnerable path / mechanism
TS-2026-014 (Tailworm T2, T3, T2-RACE, T3-RACE)tailscaled (ssh/tailssh, tailscale/gliderssh)High< 1.104.41.104.4Root-context ssh -L dial, ssh -R BindUnlink, and validateAndResolve{Listen,Socket}Path TOCTOU race
TS-2026-015 (Tailworm T1)tailscaled (ssh/tailssh, tailscale/gliderssh)High< 1.104.41.104.4Unbounded window-change goroutine pinning and environment slice appends in ssh/tailssh/session.go
TS-2026-016 (Tailworm SERVE-F-1, SERVE-F-2)tailscaled (ipn/ipnlocal/serve.go)High / Medium< 1.104.41.104.4Missing isTailscaledSocket guard in tcpHandlerForServeTCP and config-time-only symlink check on Unix HTTP backends
TS-2026-017 (Tailworm SERVE-F-5)tailscaled (ipn/ipnlocal/serve.go)Medium< 1.104.41.104.4Connection-scoped serveHTTPContext.AppCapabilities shared across concurrent HTTP/2 streams
Tailworm C3 (#21980)tailscaled (derp/derphttp)Medium< 1.104.41.104.4Unbounded io.ReadAll on non-101 HTTP upgrade responses from a hostile DERP relay
Tailworm C1 (#22003)tailscaled (feature/conn25 app connectors)High (experimental)<= 1.104.4PR #22003 (partial) / NetworkPolicyClient-registered transit IP mappings (transitIP -> dstIP) bypass destination domain validation and ACL checks
Tailworm C2, C4juanfont/headscaleMedium-High<= 0.29.5Pending upstreamUnbounded Noise registration auth-cache eviction (1,024 requests block node onboarding) and embedded-DERP sendHistory growth

Technical analysis

1. TS-2026-014: root Unix-socket forwarding and SO_PEERCRED confused deputy

Tailscale SSH runs inside the root-privileged tailscaled daemon and authenticates peers at the WireGuard layer (none method at the SSH layer) rather than prompting for passwords or SSH keys. When an ACL rule allowed a peer to connect as a non-root local user (such as ubuntu or autogroup:nonroot), tailscaled dropped privileges when spawning the interactive login shell (incubator.go), but handled SSH socket forwarding inside the root tailscaled process itself.

To prevent a non-root SSH user from forwarding to /run/docker.sock or /run/tailscale/tailscaled.sock, tailscale/gliderssh used validateAndResolveSocketPath() and validateAndResolveListenPath() to check that the requested path resolved inside the user’s home directory, /tmp, or /run/user/<uid>. Three flaws broke that boundary:

  1. Direct root dials (ssh -L, Finding 1 / T3): Any root-only (0600) socket placed inside /tmp, /run/user, or a home directory was dialed by tailscaled with uid=0 credentials.
  2. Root BindUnlink and chmod races (ssh -R, Finding 2 / T2): On remote Unix forwarding, gliderssh unlinked any existing socket at the target path as root before binding the listener, and created the socket with the root umask (0755) before running chmod(path, 0600) on the lexical path.
  3. Check-to-use rename(2) race (T2-RACE / T3-RACE, Finding 3): Because validateAndResolveSocketPath() resolved symlinks at check time and passed the lexical path string to net.Dial("unix", ...) or os.Remove() / net.Listen("unix", ...) microseconds later, an unprivileged loop running inside the attacker’s SSH session flipping a directory symlink at ~170,000 renames per second won the TOCTOU window in under a second:
    • Dial-side root escalation: Racing ssh -L against /run/docker.sock or /run/systemd/private caused the Linux kernel to record tailscaled’s credentials (SO_PEERCRED uid=0) on the connected socket. Against /run/systemd/private, systemd accepted AUTH EXTERNAL 30 (uid=0) and executed StartTransientUnit to spawn a root shell; against /run/docker.sock, the forwarded connection mounted / into a container as uid=0.
    • Bind-side LocalAPI MITM and node-key theft: Racing ssh -R against /run/tailscale/tailscaled.sock caused root tailscaled to unlink its own live LocalAPI socket and bind the attacker’s listener at /run/tailscale/tailscaled.sock, capturing live POST /localapi/v0/start authkeys and TKA signing material when an administrator ran tailscale CLI commands.

Tailscale fixed TS-2026-014 in two stages across commit 4adc3c051c and commit 19523b1c2b. First, 4adc3c051c routes all Unix -L, Unix -R, TCP -R, and SSH agent socket creation through a per-connection ssh-monitor that spawns child workers (tailscaled be-child ssh-fwd) with SysProcAttr.Credential set to the authenticated local user’s UID, GID, and supplementary groups. Second, 19523b1c2b keeps the dial worker alive to relay payload bytes rather than passing the connected service file descriptor back to tailscaled via SCM_RIGHTS (because on Linux, services using per-message SO_PASSCRED / SCM_CREDENTIALS inspect the credentials of the process calling write(2), not just the process that called connect(2)):

// ssh/tailssh/fwdchild.go (v1.104.4, commit 19523b1c2b)
// The ssh-monitor spawns forwarding children as "tailscaled
// be-child ssh-fwd", one per dial/listen op.
//
// The child runs as the connection's bound identity, applied by the
// monitor as SysProcAttr.Credential. A listener child binds and returns
// its descriptor over an inherited socketpair as one status line, then
// exits. A dial child instead returns one end of a private socketpair
// and a lifetime pipe writer, and stays alive to relay bytes to the dialed
// service as the bound user.

2. TS-2026-016 and TS-2026-017: tailscale serve Unix-socket guards and HTTP/2 capability bleed

In ipn/ipnlocal/serve.go, tailscale serve proxies tailnet traffic to local backends. Because the LocalAPI (/run/tailscale/tailscaled.sock) authenticates Unix-socket callers by their OS peer credentials (SO_PEERCRED), letting tailscaled proxy a remote peer’s connection into tailscaled.sock gives that remote peer PermitWrite root-equivalent access to the node’s LocalAPI (GET /localapi/v0/status, PATCH /localapi/v0/prefs).

Two bugs in serve.go exposed Unix sockets (TS-2026-016):

  • Missing guard on --tcp Unix forwards (commit 21b7a44c44): proxyHandlerForBackend() checked b.isTailscaledSocket(socketPath) for HTTP reverse proxies, but tcpHandlerForServeTCP() (tailscale serve --bg --tcp unix:/run/tailscale/tailscaled.sock) dialed backDst with no check at all.
  • Config-time-only symlink check (commit 7faa0a9d6f): Even on the HTTP reverse-proxy path, isTailscaledSocket() ran only once when the handler was constructed. Replacing a benign Unix socket path with a symlink to /run/tailscale/tailscaled.sock or /run/docker.sock after configuration caused every subsequent dial to follow the symlink as root.

Commit 21b7a44c44 rejects tailscaled.sock at config time in setServeConfigLocked(), and commit 7faa0a9d6f introduces dialServeUnixSocketSafe(), which pins the resolved socket inode on Linux 5.6+ using openat2(2) with O_PATH | RESOLVE_NO_MAGICLINKS | RESOLVE_BENEATH, verifies !b.isTailscaledSocketFd(fd), dials /proc/self/fd/<fd>, and re-verifies the connected peer address after connect():

--- a/ipn/ipnlocal/serve.go
+++ b/ipn/ipnlocal/serve.go
@@ -382,6 +382,10 @@ func (b *LocalBackend) setServeConfigLocked(config *ipn.ServeConfig, etag string
+	if b.serveTargetsTailscaledSocket(config.View()) {
+		return errors.New("serving to the tailscaled socket is prohibited for security reasons")
+	}

Separately, in TS-2026-017 (commit d090b5ab68), serveWebHandler stored the current mount’s accepted peer capabilities in serveHTTPContext.AppCapabilities, and addAppCapabilitiesHeader() read that field inside httputil.ReverseProxy.Rewrite. Because serveHTTPContext is allocated once per TCP connection (BaseContext + OneConnListener) and tailscale serve advertises HTTP/2 over TLS, concurrent HTTP/2 streams to two mounts on the same connection raced on c.AppCapabilities, bleeding Tailscale-App-Capabilities headers into a mount that accepted no capabilities:

--- a/ipn/ipnlocal/serve.go
+++ b/ipn/ipnlocal/serve.go
@@ -86,15 +86,21 @@ var ErrProxyToTailscaledSocket = errors.New("cannot proxy to tailscaled socket")
 var serveHTTPContextKey ctxkey.Key[*serveHTTPContext]
+var appCapsContextKey = ctxkey.New("ipnlocal.appCapsContextKey", views.Slice[peercap.Cap]{})
 
 type serveHTTPContext struct {
 	SrcAddr       netip.AddrPort
 	ForVIPService tailcfg.ServiceName // "" means local
 	DestPort      uint16
 	Funnel *funnelFlow
-	// AppCapabilities lists all PeerCapabilities that should be forwarded by serve
-	AppCapabilities views.Slice[peercap.Cap]
 }

3. TS-2026-015 (gliderssh session OOM) and DERP client ReadAll bound (#21980)

In TS-2026-015, gliderssh wrote PTY window-resize messages synchronously from the session request loop and appended env requests without a cap, while newSession() in ssh/tailssh/session.go could leave winCh undrained or blocked. Commit 64b25e6558, commit 92bc90addc, and commit 028f06144b bound environment and session queues in tailscale/gliderssh and replace stale window sizes in the 1-slot s.winCh channel without blocking. In addition, commit 1fb2bb81e6 (#21980) bounds derp/derphttp error body reads on non-101 HTTP upgrade responses so a malicious DERP relay cannot force a 1.34 GB allocation in tailscaled.

Detection and remediation

1. Check tailscale versions, active serve configs, and forwarding exposure

Run the following commands across Linux, macOS, and container nodes:

# 1. Verify tailscale and tailscaled daemon version (must be >= 1.104.4)
tailscale version

# 2. Check whether Tailscale SSH or tailscale serve is enabled on this node
tailscale status --json | jq '{Self: .Self.HostName, Version: .Version, SSH: .Self.CapMap}'
tailscale serve status --json 2>/dev/null || true

# 3. Hunt for race-leftover 0755 Unix sockets in /tmp, /run, or home directories
# (A missed post-listen chmod during the T2-RACE exploit leaves a 0755 socket owned by root)
find /tmp /run /var/run /home -type s -user root -perm 0755 2>/dev/null

2. Upgrade to v1.104.4 or apply the TS_SSH_DISABLE_FORWARDING=1 stopgap

  1. Upgrade to Tailscale v1.104.4 or newer:
    sudo tailscale update
  2. Apply the forwarding stopgap on nodes that cannot be updated immediately: Setting TS_SSH_DISABLE_FORWARDING=1 in the tailscaled environment disables local and remote TCP/Unix-socket forwarding and SSH agent forwarding while keeping interactive Tailscale SSH shell sessions working:
    echo 'TS_SSH_DISABLE_FORWARDING=1' | sudo tee -a /etc/default/tailscaled
    sudo systemctl restart tailscaled
  3. Harden tailnet SSH ACLs, headscale, and Kubernetes app connectors:
    • Scope Tailscale SSH rules in your policy file to explicit source-to-destination pairs rather than autogroup:member or broad server tags, and set per-rule forwarding flags to false on the SaaS control plane where forwarding is not needed.
    • If you run self-hosted headscale (<= 0.29.5), remember that headscale cannot disable SSH forwarding per rule; treat every SSH rule targeting an unpatched < 1.104.4 node as granting root access, and place rate limits and MemoryMax supervision in front of internet-reachable headscale servers until upstream patches for C2/C4 land.
    • If you run experimental conn25 app connectors in Kubernetes, apply a default-deny egress NetworkPolicy to the connector namespace that blocks pod and service CIDRs and 169.254.169.254/32.
    • If you suspect a host was compromised via TS-2026-014 or TS-2026-016, delete the node from the Tailscale control plane in addition to reimaging the host (reimaging alone does not invalidate a stolen /var/lib/tailscale/tailscaled.state machine/node key).

Timeline

  • 2026-10-22 to 2026-10-24: Cyberdione Labs audits tailscale v1.104.2 and headscale v0.29.5 in a lab environment.
  • 2026-10-23 to 2026-10-27: Tailscale engineers land privilege-drop forwarding monitor commits (4947a2f3d8, da7cdd36f2, 4adc3c051c, 19523b1c2b), gliderssh resource bounds (92bc90addc, 64b25e6558, 028f06144b), serve Unix-socket guards (21b7a44c44, 7faa0a9d6f), and per-request HTTP/2 capability context (d090b5ab68).
  • 2026-10-25: Cyberdione Labs publishes the Tailworm report at tailworm.com.
  • 2026-10-27 (22:46 UTC) to 2026-10-28 (01:05 UTC): Tailscale tags and publishes v1.104.4 alongside security bulletins TS-2026-014, TS-2026-015, TS-2026-016, and TS-2026-017 and its engineering postmortem.

From research to remediation

Check whether this pattern exists in your codebase

Turn this research into a remediation workflow. Scan dependencies and package manifests for similar supply-chain risk, then prioritize fixes with reachability context.

References