Security advisory · Tailscale 1.104.2 · headscale 0.29.5
Tailworm
Published
Tailscale SSH runs ssh -L and ssh -R unix-socket forwarding as root inside tailscaled. Any device an SSH rule admits can reach docker.sock, /run/systemd/private or tailscaled.sock as root, even when the rule only grants an unprivileged account. Erica Windisch of Cyberdione Labs published the findings at tailworm.com on 2026-10-24, with eleven findings in all across Tailscale SSH, serve and funnel, conn25 app connectors, the DERP client and headscale.
Every finding was reproduced in a lab, on unmodified Tailscale 1.104.2 and headscale 0.29.5, between 2026-10-22 and 2026-10-24. None has been reported in the wild. As of 2026-10-25, Tailscale has not published a bulletin, a CVE or a fixed release.
- Impact
- Root on any node an SSH rule admits; node takeover via serve
- Tested on
- Tailscale
1.104.2· headscale0.29.5 - Fixed in
- No fixed release as of 2026-10-25
- Main control
- Your SSH policy: per-pair grants, forwarding denied
- Published
[01]
One SSH rule, every node
Forwarding
Root on the far side
Findings 1 to 3 need nothing but an SSH rule that admits a device. The forwarded socket is opened by root tailscaled, so the account the rule grants does not matter: the report demonstrated root on Docker, systemd and the Tailscale localapi from fully unprivileged accounts.
Wormability
Under a second per hop
In the report's lab, policy-granted Tailscale SSH plus findings 1 to 3 spread automatically to every device the policy permitted, with no passwords, stolen keys or user interaction. Per-pair grants were the single control that stopped it.
[A]Timeline
10-22
Testing starts against Tailscale
1.104.2and headscale0.29.5.10-24
The report is published at tailworm.com, with reproduction kits. No CVE is assigned.
10-25
@swamp/cve/tailworm2026.10.24.1is published. Tailscale's security bulletins list nothing for these findings.
[B]No fix yet
- No Tailscale bulletin, CVE or fixed release as of 2026-10-25, and none for headscale.
- TS-2026-004 and TS-2026-005, fixed in
1.98.9, were earlier fixes for the same unix-socket class. Findings 3, 7 and 8 get around them. - Do not downgrade:
1.104.2is also the fix for TS-2026-013.
[C]Check mode is not a control
Check-mode rules gate session establishment only. Forwarding happens inside the approved session, so findings 1 to 3 apply for its whole life. The report: never treat check mode as a control on forwarding.
The eleven findings
| # | Component | What breaks | Severity (report) |
|---|---|---|---|
| 1 | Tailscale SSH | ssh -L unix-socket forwarding dials as root, so an admitted device writes to root-only sockets such as docker.sock | High |
| 2 | Tailscale SSH | ssh -R unix-socket forwarding replaces root-owned sockets | High |
| 3 | Tailscale SSH | A rename race defeats the {home, /tmp, /run/user} path allowlist on both the bind and the dial side | High |
| 4 | conn25 app connectors | An app entitlement lets the client pick any egress destination, including cloud metadata and Kubernetes services | High |
| 5 | Tailscale SSH (gliderssh) | One session grows root tailscaled's memory until it is killed, and it keeps growing after the session ends | High |
| 6 | headscale | Unauthenticated memory exhaustion and onboarding lockout; a hostile relay exhausts the client | Medium-High |
| 7 | tailscale serve | serve --tcp to tailscaled.sock gives remote peers read/write localapi access: node takeover | High |
| 8 | tailscale serve | The unix-proxy guard runs at config time only, so a later symlink swap gets a root dial | Medium (contained) |
| 9 | Funnel | The funnel node attribute is enforced only by the CLI | Medium (threat model) |
| 10 | TS_SERVE_ALLOW_ALL_INTERFACES | Serve listeners are not bound to the tunnel interface | Configuration note |
| 11 | serve over HTTP/2 | A shared context leaks capability headers between mounts | Medium (Low-Medium without header authorization) |
Severities are the report's own and may change when Tailscale publishes its assessment. The report marks finding 4's interaction with allowlisting as analysis, not a lab demonstration, finding 9 as a threat-model finding, and finding 10 as verified from source only.
Your SSH policy is the main control
No setting on a node closes findings 1 to 3. The report's mitigations, in order of effect, start with the tailnet policy:
- Per-pair SSH grants. One source device to one destination; remove identity-wide and tag-wide accept rules.
- Deny forwarding. On Tailscale's control plane, set the per-rule forwarding fields to false unless a documented need exists.
- No root targets. Never allow root, or accounts with passwordless sudo, as SSH target users. Limiting SSH to unprivileged accounts is defense in depth only: these findings work from fully unprivileged accounts.
On headscale, forwarding cannot be denied. headscale 0.29.5 grants local and remote forwarding on every accepted SSH session, whatever the policy says. This was confirmed against a real headscale: a policy granting only autogroup:nonroot arrived on the node with both forwarding flags set, as the scans below show. Until it is fixed, treat every headscale SSH grant as root-equivalent, or turn Tailscale SSH off and use OpenSSH.
Everything else you can do now
- Remove every
serve --tcpforward totailscaled.sockor another privileged socket (finding 7). - Restrict connector entitlements, and enforce a destination allowlist on connector hosts with netfilter, or default-deny egress on the connector's Kubernetes namespace (finding 4).
- Block
tailscaled's egress to loopback, link-local (including cloud metadata) and RFC1918 with an nftables rule on its service cgroup, allowing its legitimate destinations. - Audit unix-socket unlink, bind and chmod by
tailscaled. Firewalls never see the socket attacks in findings 1 to 3. - On headscale, set
MemoryMaxandRestart=always, rate-limit registration behind a reverse proxy, and reach it only from an admin network or VPN (finding 6). - Turn on node-key expiry for server fleets. On suspicion, delete the device at the control plane: reimaging alone does not revoke a copied node key.
[02]
Check and fix with Swamp
Step 1:Am I affected?
A node is exposed if any of these is true.
The report tested
1.104.2only. With no fixed release, the scanner treats every Tailscale version as exposed to the code findings.- Tailscale SSH is on and any SSH rule admits another device (findings 1 to 3 and 5).
- The tailnet is run by headscale: every SSH grant then includes forwarding.
tailscale serveforwards a port to aunix:path, above alltailscaled.sock(findings 7 and 8).- The node advertises an app connector with conn25 (finding 4).
- Funnel is on, or
TS_SERVE_ALLOW_ALL_INTERFACESis set (findings 9 and 10). - You run headscale reachable from the internet (finding 6).
Step 2:Install Swamp and the scanner
The @swamp/cve/tailworm extension reads each node's Tailscale version, its SSH rules, its serve, funnel and app-connector configuration, node key expiry, headscale's limits and exposure, and the report's forensic signatures.
Its probe is POSIX
shand needs only thetailscaleCLI on the target. Scanning needs no root, but only root or the Tailscale operator user can read the node's SSH rules and socket listeners.Install Swamp:
curl -fsSL https://swamp-club.com/install.sh | shInitialize a new repo (run this in a new empty directory):
swamp repo initPull the scanner:
swamp extension pull @swamp/cve/tailwormCreate the scanner, with the SSH login it uses for remote hosts:
swamp behavior create @swamp/cve/tailworm tailworm-fleet \ --global-arg sshUser=root \ --global-arg sshKey=~/.ssh/id_ed25519Am I affected (this machine):
swamp behavior run tailworm-fleet scanMitigate (a dry run unless you pass dryRun=false):
swamp behavior run tailworm-fleet mitigateScan a running container, or an image:
swamp behavior create @swamp/cve/tailworm tw-container \ --global-arg dockerContainer=tailscale swamp behavior create @swamp/cve/tailworm tw-image \ --global-arg dockerImage=tailscale/tailscale:v1.104.2 swamp behavior run tw-image scan›Local scan results
A real scan, as root, of a Debian 13 machine that runs both headscale
0.29.5and Tailscale1.104.2with Tailscale SSH on, a policy granting onlyautogroup:nonroot, and aserve --tcpforward to the localapi socket:# Tailworm Scan Results 1 host(s) scanned: 1 exposed, 0 not exposed ┌───────────┬───────────┬───────────────────────────────┬──────────────────┬───────────┬───────────┬──────┬──────────┐ │ Host │ Tailscale │ SSH │ localapi forward │ Connector │ headscale │ IOCs │ Risk │ ├───────────┼───────────┼───────────────────────────────┼──────────────────┼───────────┼───────────┼──────┼──────────┤ │ localhost │ 1.104.2 │ yes (self-hosted, 1 src, fwd) │ yes │ no │ v0.29.5 │ none │ CRITICAL │ └───────────┴───────────┴───────────────────────────────┴──────────────────┴───────────┴───────────┴──────┴──────────┘ ## Recommendations * Remove serve forwards to tailscaled.sock now: tailscale serve --tcp=<port> off (mitigate does this). * Rewrite Tailscale SSH rules as per-pair grants (one source device to one destination); remove identity-wide and tag-wide accept rules; never allow root or passwordless-sudo target accounts. Per-pair grants were the only control that stopped the worm in the report. * headscale cannot disable SSH forwarding: treat every SSH grant to this node as root-equivalent, or turn Tailscale SSH off here (tailscale set --ssh=false; mitigate with disableTailscaleSsh=true) and use OpenSSH. * headscale: set MemoryMax and Restart=always (mitigate does this), put a rate-limiting reverse proxy in front of the registration endpoints, and keep it reachable only from an admin network or VPN. * Restrict tailscaled egress with nftables (mitigate blocks TCP to cloud metadata from the tailscaled cgroup and forwarded tailnet traffic to it); extend it to loopback and RFC1918 with an allowlist for this node's legitimate destinations. * Audit socket lifecycle: auditd rules on unlink/bind/chmod by tailscaled (mitigate installs them). Firewalls cannot see the unix-socket attacks. * No fixed Tailscale release yet: watch https://tailscale.com/security-bulletins and re-scan with the fixedVersion global argument when it ships. ## Next Step Run mitigate as a dry run first on the exposed hosts: swamp behavior run tailworm mitigate Source: https://tailworm.com (lab-reproduced findings against Tailscale 1.104.2 and headscale 0.29.5). No fixed Tailscale release had been published.The SSH column comes from the node's own rules. This is how they set the grade:
What the node's SSH rules allow SSH exposure No rule admits anyone low Forwarding denied on every rule medium: finding 5 only Forwarding granted, or check-mode rules high The same, plus root logins, any source, or a self-hosted control server critical Step 3:Scan your fleet
Pass a comma-separated list of hosts and
scanFleetscans them in parallel over SSH. A host that needs a different login takes it asuser@host; plain entries usesshUser, and every host usessshKey.The SSH rules a scan reads are that node's view of the policy, so scan every SSH-enabled node. A host it cannot reach is reported as an error, never as clean.
swamp behavior run tailworm-fleet scanFleet \ --input hosts=192.0.2.10,192.0.2.11,admin@192.0.2.5On Linux nodes the scan also looks for the report's forensic signatures. Only the critical ones turn the advice into incident response, and a clean check does not rule out a past compromise: the race leaves no trace once the attacker's socket is gone.
Signature (Linux) Severity Why tailscaled.sock not mode 0666 critical tailscaled creates it 0666; 0600 is the fingerprint of a race-won forward bound over it tailscaled listening on a unix socket outside the forwarding directories critical A forward has replaced a privileged socket tailscaled listening on a socket in /tmp, /home, /run/user or /root medium An active ssh -R unix forward: confirm who opened it Root-owned 0755 socket in /tmp, /home or another user's /run/user high The report's sign of a race-won bind; investigate, it is not proof ›Fleet scan results
A real scan of a three-host lab: two Debian 13 nodes on Tailscale
1.104.2with Tailscale SSH on, joined to a headscale0.29.5server at192.0.2.5whose policy grants onlyautogroup:nonroot, and192.0.2.10with a serve forward to the localapi. The headscale host runs no Tailscale, so its signatures are not checked:# Tailworm Scan Results 3 host(s) scanned: 3 exposed, 0 not exposed ┌────────────┬───────────┬───────────────────────────────┬──────────────────┬───────────┬───────────┬─────────────┬──────────┐ │ Host │ Tailscale │ SSH │ localapi forward │ Connector │ headscale │ IOCs │ Risk │ ├────────────┼───────────┼───────────────────────────────┼──────────────────┼───────────┼───────────┼─────────────┼──────────┤ │ 192.0.2.10 │ 1.104.2 │ yes (self-hosted, 2 src, fwd) │ yes │ no │ — │ none │ CRITICAL │ ├────────────┼───────────┼───────────────────────────────┼──────────────────┼───────────┼───────────┼─────────────┼──────────┤ │ 192.0.2.11 │ 1.104.2 │ yes (self-hosted, 2 src, fwd) │ no │ no │ — │ none │ CRITICAL │ ├────────────┼───────────┼───────────────────────────────┼──────────────────┼───────────┼───────────┼─────────────┼──────────┤ │ 192.0.2.5 │ — │ — │ — │ — │ v0.29.5 │ not checked │ HIGH │ └────────────┴───────────┴───────────────────────────────┴──────────────────┴───────────┴───────────┴─────────────┴──────────┘ ## Recommendations * Remove serve forwards to tailscaled.sock now: tailscale serve --tcp=<port> off (mitigate does this). * Rewrite Tailscale SSH rules as per-pair grants (one source device to one destination); remove identity-wide and tag-wide accept rules; never allow root or passwordless-sudo target accounts. Per-pair grants were the only control that stopped the worm in the report. * headscale cannot disable SSH forwarding: treat every SSH grant to this node as root-equivalent, or turn Tailscale SSH off here (tailscale set --ssh=false; mitigate with disableTailscaleSsh=true) and use OpenSSH. * No fixed Tailscale release yet: watch https://tailscale.com/security-bulletins and re-scan with the fixedVersion global argument when it ships. * headscale: set MemoryMax and Restart=always (mitigate does this), put a rate-limiting reverse proxy in front of the registration endpoints, and keep it reachable only from an admin network or VPN. ## Next Step Run mitigate as a dry run first on the exposed hosts: swamp behavior run tailworm-fleet mitigate --input hosts=192.0.2.10,192.0.2.11,admin@192.0.2.5 Source: https://tailworm.com (lab-reproduced findings against Tailscale 1.104.2 and headscale 0.29.5). No fixed Tailscale release had been published.Step 4:Mitigate across your fleet
mitigatescans first, then applies the report's interim mitigations on exposed hosts. It removes serve forwards totailscaled.sock, blocks forwarded tailnet traffic andtailscaled's own TCP to cloud metadata with nftables, installs auditd rules fortailscaled, and caps headscale's memory. Every host is scanned again afterwards.It cannot change the tailnet policy, so a node stays critical until you rewrite the SSH rules. In a container, it changes only Tailscale itself.
dryRundefaults totrue— the method shows the plan and changes nothing:swamp behavior run tailworm-fleet mitigate \ --input hosts=192.0.2.10,192.0.2.11,admin@192.0.2.5Pass
dryRun=falseto apply:swamp behavior run tailworm-fleet mitigate \ --input hosts=192.0.2.10,192.0.2.11,admin@192.0.2.5 \ --input dryRun=falseTurning Tailscale SSH off is opt-in and runs last, because it drops every session over Tailscale SSH, including one Swamp connects over.
disableAppConnector=truestops advertising the app connector the same way:swamp behavior run tailworm-fleet mitigate \ --input hosts=192.0.2.10 \ --input dryRun=false \ --input disableTailscaleSsh=trueMitigation needs root, or passwordless sudo for the exact commands the dry run prints. Each runs as
sudo -nwith no shell wrapper, and every one is checked before anything changes; if one would ask for a password, the host is reported asneeds-privilegeand nothing is changed. These rights amount to root, so pin them to the dry run's command lines. This entry covers the steps every Ubuntu 24.04 node with nftables and auditd gets:ops ALL=(root) NOPASSWD: /usr/bin/tee /etc/tailworm-egress.nft, \ /usr/sbin/nft -f /etc/tailworm-egress.nft, \ /usr/bin/tee /etc/tailworm-egress-cgroup.nft, \ /usr/bin/mkdir -p /etc/systemd/system/tailscaled.service.d, \ /usr/bin/tee /etc/systemd/system/tailscaled.service.d/50-tailworm.conf, \ /usr/bin/systemctl daemon-reload, \ /usr/sbin/nft -f /etc/tailworm-egress-cgroup.nft, \ /usr/bin/tee /etc/audit/rules.d/tailworm.rules, \ /usr/sbin/augenrules --loadAdd each node's own lines from its dry run: one
tailscale serve --tcp=<port> offper localapi forward, the headscale steps, and the opt-intailscale setsteps. Without them a node with a forward reportsneeds-privilegeand nothing is applied. sudoers needs=escaped inside arguments, for example/usr/bin/tailscale serve --tcp\=8443 off.DNS on port 53 is left open. If a node logs in with cloud workload identity, which fetches its token from the metadata service, run mitigate with
applyEgressRules=false.›Mitigation results
A dry run on the same Debian 13 machine, scanning itself as root. The behavior there was created as
tailworm, and the report's commands name the behavior that ran:# Tailworm Mitigation: DRY RUN ┌───────────┬─────────┬───────────┬──────────┐ │ Host │ Outcome │ Privilege │ Risk now │ ├───────────┼─────────┼───────────┼──────────┤ │ localhost │ dry-run │ root │ CRITICAL │ └───────────┴─────────┴───────────┴──────────┘ ## localhost /usr/bin/tailscale serve --tcp=8443 off /usr/bin/tee /etc/tailworm-egress.nft /usr/sbin/nft -f /etc/tailworm-egress.nft /usr/bin/tee /etc/tailworm-egress-cgroup.nft /usr/bin/mkdir -p /etc/systemd/system/tailscaled.service.d /usr/bin/tee /etc/systemd/system/tailscaled.service.d/50-tailworm.conf /usr/bin/systemctl daemon-reload /usr/sbin/nft -f /etc/tailworm-egress-cgroup.nft /usr/bin/tee /etc/audit/rules.d/tailworm.rules /usr/sbin/augenrules --load /usr/bin/mkdir -p /etc/systemd/system/headscale.service.d /usr/bin/tee /etc/systemd/system/headscale.service.d/50-tailworm.conf /usr/bin/systemctl daemon-reload /usr/bin/systemctl set-property --runtime headscale.service MemoryMax=2G * The cgroup rule blocks tailscaled's own TCP to the metadata service (DNS on port 53 excepted). If this node logs in with cloud workload identity, which fetches its token from that service, run mitigate with applyEgressRules=false. * headscale listens on 0.0.0.0:8080: restrict it to an admin network or VPN and rate-limit registration with a reverse proxy. * The SSH policy itself is the main control: rewrite it as per-pair grants and deny forwarding. mitigate cannot change the tailnet policy. To apply: swamp behavior run tailworm mitigate --input dryRun=falseThen with
dryRun=false. Each command's exit status is recorded, and a failed step stops the rest of its phase:# Tailworm Mitigation: APPLIED ┌───────────┬─────────┬───────────┬──────────┐ │ Host │ Outcome │ Privilege │ Risk now │ ├───────────┼─────────┼───────────┼──────────┤ │ localhost │ applied │ root │ CRITICAL │ └───────────┴─────────┴───────────┴──────────┘ ## localhost [ok] /usr/bin/tailscale serve --tcp=8443 off [ok] /usr/bin/tee /etc/tailworm-egress.nft [ok] /usr/sbin/nft -f /etc/tailworm-egress.nft [ok] /usr/bin/tee /etc/tailworm-egress-cgroup.nft [ok] /usr/bin/mkdir -p /etc/systemd/system/tailscaled.service.d [ok] /usr/bin/tee /etc/systemd/system/tailscaled.service.d/50-tailworm.conf [ok] /usr/bin/systemctl daemon-reload [ok] /usr/sbin/nft -f /etc/tailworm-egress-cgroup.nft [ok] /usr/bin/tee /etc/audit/rules.d/tailworm.rules [ok] /usr/sbin/augenrules --load [ok] /usr/bin/mkdir -p /etc/systemd/system/headscale.service.d [ok] /usr/bin/tee /etc/systemd/system/headscale.service.d/50-tailworm.conf [ok] /usr/bin/systemctl daemon-reload [ok] /usr/bin/systemctl set-property --runtime headscale.service MemoryMax=2G * The cgroup rule blocks tailscaled's own TCP to the metadata service (DNS on port 53 excepted). If this node logs in with cloud workload identity, which fetches its token from that service, run mitigate with applyEgressRules=false. * headscale listens on 0.0.0.0:8080: restrict it to an admin network or VPN and rate-limit registration with a reverse proxy. * The SSH policy itself is the main control: rewrite it as per-pair grants and deny forwarding. mitigate cannot change the tailnet policy. Verify with: swamp behavior run tailworm scanThe scan afterwards. The localapi forward is gone and headscale is capped, but the node is still critical: its SSH rules still grant forwarding, and on headscale only the policy or turning Tailscale SSH off changes that:
# Tailworm Scan Results 1 host(s) scanned: 1 exposed, 0 not exposed ┌───────────┬───────────┬───────────────────────────────┬──────────────────┬───────────┬───────────┬──────┬──────────┐ │ Host │ Tailscale │ SSH │ localapi forward │ Connector │ headscale │ IOCs │ Risk │ ├───────────┼───────────┼───────────────────────────────┼──────────────────┼───────────┼───────────┼──────┼──────────┤ │ localhost │ 1.104.2 │ yes (self-hosted, 1 src, fwd) │ no │ no │ v0.29.5 │ none │ CRITICAL │ └───────────┴───────────┴───────────────────────────────┴──────────────────┴───────────┴───────────┴──────┴──────────┘ ## Recommendations * Rewrite Tailscale SSH rules as per-pair grants (one source device to one destination); remove identity-wide and tag-wide accept rules; never allow root or passwordless-sudo target accounts. Per-pair grants were the only control that stopped the worm in the report. * headscale cannot disable SSH forwarding: treat every SSH grant to this node as root-equivalent, or turn Tailscale SSH off here (tailscale set --ssh=false; mitigate with disableTailscaleSsh=true) and use OpenSSH. * headscale: set MemoryMax and Restart=always (mitigate does this), put a rate-limiting reverse proxy in front of the registration endpoints, and keep it reachable only from an admin network or VPN. * No fixed Tailscale release yet: watch https://tailscale.com/security-bulletins and re-scan with the fixedVersion global argument when it ships. ## Next Step Run mitigate as a dry run first on the exposed hosts: swamp behavior run tailworm mitigate Source: https://tailworm.com (lab-reproduced findings against Tailscale 1.104.2 and headscale 0.29.5). No fixed Tailscale release had been published.
[03]
References
Find every node an SSH rule can reach
Pull the scanner, run it against every Tailscale node and headscale server you own, then rewrite the SSH policy.
swamp extension pull @swamp/cve/tailworm