Community infrastructure has a private administration plane, and most of it is a mesh VPN. The servers have no exposed SSH — that is the point of the design — and the operator reaches them through a tailnet: WireGuard peers, a policy file, and, increasingly, tailscale ssh for connections without key management. Kryptonwolf's standing argument is that communities benefit when that plane is built on the open-source client with a self-hosted control plane instead of a SaaS login, and that is precisely the configuration this new audit examined: not the marketing site, not the managed service — tailscale v1.104.2, headscale v0.29.5, the gliderssh SSH library underneath them, and the experimental conn25 connector, all unmodified released code, tested 22–24 October and published Saturday morning with reproduction kits and byte-exact PoC hashes. The write-up lives at tailworm.com and its author is Erica Windisch of Cyberdione Labs; the Hacker News thread carried it Friday, the vendor received the report Saturday morning and saw it published two hours later, and Wednesday Tailscale shipped the fix and its own account: v1.104.4, four bulletins, and a list of what changed. This post is the operator's version.
The model underneath, because the findings only make sense on top of it¶
Tailscale SSH does no user-level authentication. The SSH authentication phase completes with method none — the WireGuard layer has already cryptographically identified the device — and the policy file evaluates device-to-device pairs, naming which local Unix accounts the peer may connect as. No password, no key pair, no user credential is ever evaluated. The audit's point is not that this is undocumented; it is that one rule and one foothold now compose into everything below. A compromised web app, a poisoned build dependency, a phished laptop — any user's malware on an entitled device speaks with that device's full authorization, because the credential is the device.
Two design properties hand an attacker targeting knowledge for free: every node receives the full peer list regardless of what policy permits it to reach, and reachability probing is silent — a connection attempt distinguishes policy-dropped (timeout), policy-rejected (fast refusal), and permitted (the service answers), so a parallel sweep maps the entire reachable envelope in minutes without tripping anything, because every probe looks like an ordinary connection attempt. The attacker's map is not discovery-limited; the mesh prints it for them.
What an ordinary session could do on a released version¶
The three central findings share one root cause, and they were all verified by reproduction, not inference:
ssh -L dials as root (finding 1). An authenticated peer with a policy rule permitting local forwarding asks the root-privileged tailscaled process to connect, on the peer's behalf, to unix sockets restricted to root. The report's verified chain: forward to a Docker socket, mount / into a container, read /etc/shadow — uid=0(root) with no sudo, no docker group membership, nothing but the accepted SSH rule.
ssh -R replaces root-owned sockets (finding 2). The daemon unlinks a sticky-directory-protected, root-owned socket and binds the attacker's forwarding listener at that real path — impersonating the service on the path the rest of the system already trusts. Against a command-channel daemon that is one crafted message to code execution at uid 0. Against Docker it is cleartext registry-credential capture: the report observed credential POSTs arrive in cleartext through the attacker's listener.
The path allowlist is not a boundary (finding 3). Forwarding is supposed to be restricted to {home, /tmp, /run/user}. The check validates the lexical path at check time; the kernel re-resolves it at use time, and a rename race at ~170k path-state flips per second wins that gap routinely. Verified outcomes: a raced bind at a live root control socket with the daemon's root identity presented on every dial — 111 raced dials to a kubelet socket, every one arriving as uid=0 comm=tailscaled; the tailscaled localapi's POST /localapi/v0/start captured byte-exact with an enrollment key inside; that key used on a scratch node to enroll a rogue node under the victim's identity on a lock-disabled tailnet; the headscale admin CLI captured in plaintext h2c through a hijacked socket (the headscale CLI uses no TLS on its unix socket); and, from the chmod/unlink variants, an unprivileged session deleting arbitrary root-owned files and chmod-ing them 0600 — which the report notes corrects its own earlier scoping claim. That correction is in the report itself, which is how you can tell the author is reading the feedback loop and updating.
There is also finding 5 (a memory-exhaustion loop in gliderssh session bookkeeping that parks a goroutine per window-change send — ~536 MB allocated in seven seconds in the root daemon) and, on the serve side, the one-line configuration that turns node takeover into a race-free certainty: tailscale serve --bg --tcp <port> unix:/run/tailscale/tailscaled.sock forwards raw TCP from an authorized peer into the daemon's own localapi socket — dialed by tailscaled itself, so the peer credential is the daemon's uid, which carries local read and write — lab-verified GET /localapi/v0/status and PATCH /v0/prefs returning 200 from a remote peer. The HTTP-proxy path on the same socket was guarded; the TCP path missed the guard. Both are fixed in v1.104.4. One practical sentence if you remember nothing else from this section: audit every serve config for TCP forwards pointed at unix sockets.
Headscale specifically, because that is the sovereignty choice¶
The report is equally blunt about the self-hosted control plane the community-infra crowd prefers:
- Forwarding cannot be disabled on headscale v0.29.5. The audit's mitigation states it without hedging: forwarding "cannot be disabled — it is granted unconditionally on every accepted session, so until fixed, treat every headscale SSH grant as root-equivalent." On the SaaS plane, forwarding is per-rule and can be denied per rule. On the self-hosted plane, the flag does not exist, which inverts the usual security ranking of the two deployments. The client-side fix is the actual mitigation; the headscale-side follow-ups were still pending at the report's 28 October snapshot.
- The control plane itself has a pre-authentication denial-of-service. The noise auth cache holds 1024 tiny registrations — evicting pending device approvals — so an unauthenticated internet-facing headscale can be wedged into a state where new devices cannot onboard, organization-wide, and it fails closed. Adding to that, the vendored embedded DERP relay grows its send history unboundedly from an authenticated insider, and the client's DERP response reader turned a 10 MB response into 1.34 GB of memory on the client side (586 MB measured) with the error string embedding the whole body. Client-side bound now shipped in v1.104.4; the headscale-side work is pending. If your headscale is internet-reachable: memory limits, restart supervision, rate limiting in front of it, today.
Check mode is not a forwarding control¶
Tailscale SSH's check rules force a browser re-authentication before a session opens, and the audit took that control apart because so many deployments rely on it for the sensitive targets. Three measured limits, all worth reading in the original:
- It gates session establishment only. Forwarding happens inside an established, human-approved session — after the check, findings 1–3 apply for the session's lifetime. The vendor's own recommendation to "consider check mode" for sensitive systems survives, but only as a gate on who connects, never on what a connected session may do.
- On the SaaS control plane, one approval authorizes the user for all check-mode destinations for the window — one authentication amortizes fleet-wide. The self-hosted control plane came out measurably stronger here: headscale scopes the window to the destination pair. Second inversion in this report where the self-hosted option wins.
- Piggybacking is real: an attacker already inside the tailnet does not need to phish the prompt — they wait for the operator's next legitimate authentication, and (on the SaaS plane) its fleet-wide window covers the attacker's targets too. And because the audit also contains unauthenticated DoS findings, it names the darkest composition: an attacker can manufacture the conditions that make an administrator re-authenticate as part of repair. Its operational rule is the right one: an unrequested Tailscale authentication prompt is an incident, and a check-window opening during an active incident is attacker-relevant until proven otherwise.
What actually shipped, and the architecture argument underneath¶
The vendor's findings-and-fixes post and the audit's mitigation matrix agree on the bookkeeping: findings 1–3 (the forwarding class) fixed via a privilege-drop monitor — forwarded dials, binds, unlinks now run as the session user; the session-pin memory loop fixed in gliderssh; the serve TCP-to-localapi forward refused at config time and per dial; the serve guard TOCTOU fixed with openat2 inode pinning; the header-capability race fixed per-request. Partial: the conn25 unrestricted-egress finding has an open PR, and the headscale-side cache/DERP work pending; the client-side DERP bound is in v1.104.4. Not changed: two of the five Serve/Funnel findings were classified as working-as-designed (a Funnel attribute bypass that requires local admin, and the TS_SERVE_ALLOW_ALL_INTERFACES knob, which exists to do exactly what it says — keep it unset), and the funnel-entitlement enforcement gap — attributes enforced only by the CLI, never daemon-side — got no fix at this snapshot; the audit's operator action for it is simply "do not rely on CLI-only funnel gates." conn25's stable app connectors are not affected; the finding that matters there was against unreleased pre-production code the vendor already knew about.
Read those statuses against the audit's comparative class study, because it is the part with the longest tail. The bug class is not hypothetical or novel: Dropbear had the dial-side instance (CVE-2025-14282, fixed in 2025.89) by privileged-SO_PEERCRED on race-won unix dials, and OpenSSH is clean on defaults for one structural reason — the post-auth child drops to the session user before the server loop, so there is no root actor and no check/use gap to race, and its upstream now explicitly force-disables streamlocal and privileged-port forwarding whenever post-auth sshd would run as root: a one-line statement that forwarding features assume user privilege. gliderssh runs those same features as root, having inherited the socket API without the privilege separation. The audit's recommended fix is therefore not exotic — it is the twenty-year-old OpenSSH pattern, and per the mitigation matrix, that is what landed: restore the privilege drop. The researcher's one-line framing, "we have zero-trust in our infrastructure," lands where it does because the product's convenience pitch — no key management, identity-is-the-credential — is built by removing exactly the layer that OpenSSH, Dropbear, and a decade of sshd hardening kept: separate the privileged monitor from the session. Convenience removed it. The fix reinstates it.
The week's checklist, in order of effect¶
- Upgrade tailscaled to 1.104.4 or later on every node — the fix is client-side, which for headscale deployments means all of them, including the unglamorous ones. That is the only mitigation for the class when running headscale, where policy cannot turn forwarding off.
- Rewrite SSH policy as per-pair grants. Never identity-wide or tag-wide accept rules, never privileged target accounts. In the lab this single control ended the worm; propagation was under a second per hop without it.
- Audit serve configs for TCP forwards to unix sockets (finding 7's route), and re-validate unix backends after any config change (finding 8's TOCTOU).
- On SaaS, deny the per-rule forwarding flags where a documented need does not exist.
- headscale exposed to the internet: memory limits (
MemoryMax), restart supervision, registration-rate limiting, and monitor resident memory and registration-cache churn — the report notes it retains peak RSS after an attack. - If you use conn25: restrict entitlements, destination-allowlist the connector hosts (netfilter), or default-deny egress NetworkPolicy on its Kubernetes namespace.
- Node-key expiry on server fleets; TPM/secure-enclave-bound agent state where the hardware has it — those are what made identity theft temporary in the lab.
Containment, if it has already happened¶
Re-imaging is not containment. A node's identity is key material in its state file; the report demonstrated the copied file re-creating a node's identity on other hardware, accepted by the control plane. Delete the device at the control plane — which invalidates its machine and node keys — then rotate every authkey and credential reachable from the affected host, rotate the Tailnet Lock signing material if a signing node was compromised, and walk the policy GitOps history for unauthorized diffs (routes, DERP map, approvers, key scopes). Assume exfiltration on any root compromise. None of that is fun; all of it is cheaper than a rogue node that looks official in your node list.
What this says about disclosure, briefly and honestly¶
The researcher gave the vendor two hours on a Saturday and justified it in the report: the software is BSD-3 with no warranty, "the researchers, and the users, are effectively developers of said software per its open source nature," and "it is the opinion of the researcher that infrastructure is vulnerable today." The vendor's response — investigating from Friday's social media noise before the report arrived, fixes tagged three days after publication, four bulletins, a findings post that concedes the core mechanism and its own recommendation, and a per-finding status matrix carrying the vendor's open items in a third party's report — is the fastest honest turnaround this site has covered in the mesh-VPN category, and the two-hour deadline did not cost accuracy this time because the code was open and the reproductions were already public. Both things are true at once. What the report gets credit for that the thread coverage lost: it states plainly what did not hold up as bugs — two Serve/Funnel findings are documented behavior, wormability is bounded by per-pair policy and hardware-bound credentials, and the audit measured the one control that stopped the spread rather than only the ones that failed. That is what an audit looks like when the author is not selling endpoint software.
The closing note is for the community-operator readers this site writes for: the reason to run the mesh VPN you control is not that your control plane saves you from client bugs — this week proves the opposite, since the SSH defect lived in the same tailscaled every deployment runs. Self-hosting the control plane is worth it for the check-mode scoping it does better, for the policy history it keeps in your Git, and for the control-plane deletion power that containment depends on. The client is shared code either way, and the week this client shipped a root daemon running forwarding as root, "update the client" was the one line every deployment type had in common.