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):
- 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,tailscaledrandirect-streamlocal(ssh -L) andstreamlocal-forward(ssh -R) inside the root-privileged daemon and relied on a lexical allowlist (home,/tmp,/run/user) checked byvalidateAndResolveSocketPath(). 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 -RBindUnlink), or run arename(2)symlink race inside the SSH session to defeat the lexical check and reach/run/docker.sock,/run/systemd/private(SO_PEERCREDuid=0), or/run/tailscale/tailscaled.sockin under a second per hop. - 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 grewtailscaledRSS by ~536 MB in 7 seconds, persisting after the client disconnected until the Linux OOM killer terminatedtailscaledand dropped all tailnet connectivity on the host. tailscale serveUnix-socket LocalAPI takeover and symlink swap (TS-2026-016, High / Medium): Whiletailscale serveHTTP proxying blockedtailscaled.sockviaisTailscaledSocket(), the raw TCP forwarder (tailscale serve --bg --tcp unix:/run/tailscale/tailscaled.sock) had no check and dialed the LocalAPI socket asroot, handing any ACL-authorized remote peerPermitWriteadministrative control over the node. In addition, the HTTP proxy’sisTailscaledSocket()check ran only at config load time rather than per dial, allowing a post-config symlink swap to retarget requests attailscaled.sockor/run/docker.sock.- HTTP/2
Tailscale-App-Capabilitiesbleed acrosstailscale servemounts (TS-2026-017, Medium):serveWebHandlerstored per-mountAppCapabilitieson a single connection-scopedserveHTTPContextstruct shared across all multiplexed HTTP/2 streams on a TLS connection. Racing concurrent requests to an unprivileged mount and a restricted mount causedaddAppCapabilitiesHeader()to forward the privilegedTailscale-App-Capabilitiesheader to the wrong backend mount.
What to do now:
- Upgrade all nodes running Tailscale SSH,
tailscale serve, or DERP clients tov1.104.4or newer (sudo tailscale updateor package manager upgrade). - Where nodes cannot be upgraded immediately, set
TS_SSH_DISABLE_FORWARDING=1in/etc/default/tailscaledand restarttailscaled, 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:memberSSH rules with specific source-to-target grants, and note that Tailscale SSHaction: checkdoes not blockTS-2026-014once a session is established. On self-hostedheadscalev0.29.5, forwarding cannot be disabled in policy and is granted on every SSH rule, so upgradetailscaledon every target host first.
Affected packages and components
| Bulletin / Finding | Component | Severity | Affected versions | Fixed version | Vulnerable path / mechanism |
|---|---|---|---|---|---|
TS-2026-014 (Tailworm T2, T3, T2-RACE, T3-RACE) | tailscaled (ssh/tailssh, tailscale/gliderssh) | High | < 1.104.4 | 1.104.4 | Root-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.4 | 1.104.4 | Unbounded 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.4 | 1.104.4 | Missing 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.4 | 1.104.4 | Connection-scoped serveHTTPContext.AppCapabilities shared across concurrent HTTP/2 streams |
Tailworm C3 (#21980) | tailscaled (derp/derphttp) | Medium | < 1.104.4 | 1.104.4 | Unbounded 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.4 | PR #22003 (partial) / NetworkPolicy | Client-registered transit IP mappings (transitIP -> dstIP) bypass destination domain validation and ACL checks |
Tailworm C2, C4 | juanfont/headscale | Medium-High | <= 0.29.5 | Pending upstream | Unbounded 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:
- Direct root dials (
ssh -L, Finding 1 /T3): Any root-only (0600) socket placed inside/tmp,/run/user, or a home directory was dialed bytailscaledwithuid=0credentials. - Root
BindUnlinkandchmodraces (ssh -R, Finding 2 /T2): On remote Unix forwarding,glidersshunlinked any existing socket at the target path asrootbefore binding the listener, and created the socket with the root umask (0755) before runningchmod(path, 0600)on the lexical path. - Check-to-use
rename(2)race (T2-RACE/T3-RACE, Finding 3): BecausevalidateAndResolveSocketPath()resolved symlinks at check time and passed the lexical path string tonet.Dial("unix", ...)oros.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 -Lagainst/run/docker.sockor/run/systemd/privatecaused the Linux kernel to recordtailscaled’s credentials (SO_PEERCREDuid=0) on the connected socket. Against/run/systemd/private, systemd acceptedAUTH EXTERNAL 30(uid=0) and executedStartTransientUnitto spawn a root shell; against/run/docker.sock, the forwarded connection mounted/into a container asuid=0. - Bind-side LocalAPI MITM and node-key theft: Racing
ssh -Ragainst/run/tailscale/tailscaled.sockcaused roottailscaledto unlink its own live LocalAPI socket and bind the attacker’s listener at/run/tailscale/tailscaled.sock, capturing livePOST /localapi/v0/startauthkeys and TKA signing material when an administrator rantailscaleCLI commands.
- Dial-side root escalation: Racing
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
--tcpUnix forwards (commit21b7a44c44):proxyHandlerForBackend()checkedb.isTailscaledSocket(socketPath)for HTTP reverse proxies, buttcpHandlerForServeTCP()(tailscale serve --bg --tcp unix:/run/tailscale/tailscaled.sock) dialedbackDstwith 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.sockor/run/docker.sockafter configuration caused every subsequent dial to follow the symlink asroot.
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
- Upgrade to Tailscale
v1.104.4or newer:sudo tailscale update - Apply the forwarding stopgap on nodes that cannot be updated immediately: Setting
TS_SSH_DISABLE_FORWARDING=1in thetailscaledenvironment 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 - 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:memberor broad server tags, and set per-rule forwarding flags tofalseon the SaaS control plane where forwarding is not needed. - If you run self-hosted
headscale(<= 0.29.5), remember thatheadscalecannot disable SSH forwarding per rule; treat every SSH rule targeting an unpatched< 1.104.4node as grantingrootaccess, and place rate limits andMemoryMaxsupervision in front of internet-reachableheadscaleservers until upstream patches forC2/C4land. - If you run experimental
conn25app connectors in Kubernetes, apply a default-deny egressNetworkPolicyto the connector namespace that blocks pod and service CIDRs and169.254.169.254/32. - If you suspect a host was compromised via
TS-2026-014orTS-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.statemachine/node key).
- Scope Tailscale SSH rules in your policy file to explicit source-to-destination pairs rather than
Timeline
- 2026-10-22 to 2026-10-24: Cyberdione Labs audits
tailscalev1.104.2andheadscalev0.29.5in a lab environment. - 2026-10-23 to 2026-10-27: Tailscale engineers land privilege-drop forwarding monitor commits (
4947a2f3d8,da7cdd36f2,4adc3c051c,19523b1c2b),glidersshresource bounds (92bc90addc,64b25e6558,028f06144b),serveUnix-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 publishesv1.104.4alongside security bulletinsTS-2026-014,TS-2026-015,TS-2026-016, andTS-2026-017and 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
- Tailscale Security Bulletins TS-2026-014, TS-2026-015, TS-2026-016, and TS-2026-017
- Tailscale Blog (28 October 2026): Tailscale SSH, findings and fixes (v1.104.4)
- Cyberdione Labs (25 to 28 October 2026): Tailworm security assessment of Tailscale v1.104.2 and headscale v0.29.5
- tailscale/tailscale commit 4adc3c051c: route SSH forwarding through the privileged monitor (TS-2026-014)
- tailscale/tailscale commit 19523b1c2b: relay Unix forward payloads as the bound user (TS-2026-014)
- tailscale/tailscale commits 21b7a44c44 and 7faa0a9d6f: refuse serving TCP to tailscaled.sock and pin Unix sockets per dial (TS-2026-016)
- tailscale/tailscale commit d090b5ab68: fix serve app caps leaking across mounts (TS-2026-017)