Skip to main content

Security advisory · Tailscale 1.104.2 · headscale 0.29.5

Tailworm

Severity · HighNo CVE assignedTailscale · headscale

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 · headscale 0.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

  1. 10-22

    Testing starts against Tailscale 1.104.2 and headscale 0.29.5.

  2. 10-24

    The report is published at tailworm.com, with reproduction kits. No CVE is assigned.

  3. 10-25

    @swamp/cve/tailworm 2026.10.24.1 is 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.2 is 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

#ComponentWhat breaksSeverity (report)
1Tailscale SSHssh -L unix-socket forwarding dials as root, so an admitted device writes to root-only sockets such as docker.sockHigh
2Tailscale SSHssh -R unix-socket forwarding replaces root-owned socketsHigh
3Tailscale SSHA rename race defeats the {home, /tmp, /run/user} path allowlist on both the bind and the dial sideHigh
4conn25 app connectorsAn app entitlement lets the client pick any egress destination, including cloud metadata and Kubernetes servicesHigh
5Tailscale SSH (gliderssh)One session grows root tailscaled's memory until it is killed, and it keeps growing after the session endsHigh
6headscaleUnauthenticated memory exhaustion and onboarding lockout; a hostile relay exhausts the clientMedium-High
7tailscale serveserve --tcp to tailscaled.sock gives remote peers read/write localapi access: node takeoverHigh
8tailscale serveThe unix-proxy guard runs at config time only, so a later symlink swap gets a root dialMedium (contained)
9FunnelThe funnel node attribute is enforced only by the CLIMedium (threat model)
10TS_SERVE_ALLOW_ALL_INTERFACESServe listeners are not bound to the tunnel interfaceConfiguration note
11serve over HTTP/2A shared context leaks capability headers between mountsMedium (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 --tcp forward to tailscaled.sock or 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 MemoryMax and Restart=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

  1. Step 1:Am I affected?

    A node is exposed if any of these is true.

    The report tested 1.104.2 only. 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 serve forwards a port to a unix: path, above all tailscaled.sock (findings 7 and 8).
    • The node advertises an app connector with conn25 (finding 4).
    • Funnel is on, or TS_SERVE_ALLOW_ALL_INTERFACES is set (findings 9 and 10).
    • You run headscale reachable from the internet (finding 6).
  2. 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 sh and needs only the tailscale CLI 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 | sh

    Initialize a new repo (run this in a new empty directory):

    swamp repo init

    Pull the scanner:

    swamp extension pull @swamp/cve/tailworm

    Create 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_ed25519

    Am I affected (this machine):

    swamp behavior run tailworm-fleet scan

    Mitigate (a dry run unless you pass dryRun=false):

    swamp behavior run tailworm-fleet mitigate

    Scan 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.5 and Tailscale 1.104.2 with Tailscale SSH on, a policy granting only autogroup:nonroot, and a serve --tcp forward 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 allowSSH exposure
    No rule admits anyonelow
    Forwarding denied on every rulemedium: finding 5 only
    Forwarding granted, or check-mode ruleshigh
    The same, plus root logins, any source, or a self-hosted control servercritical
  3. Step 3:Scan your fleet

    Pass a comma-separated list of hosts and scanFleet scans them in parallel over SSH. A host that needs a different login takes it as user@host; plain entries use sshUser, and every host uses sshKey.

    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.5

    On 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)SeverityWhy
    tailscaled.sock not mode 0666criticaltailscaled creates it 0666; 0600 is the fingerprint of a race-won forward bound over it
    tailscaled listening on a unix socket outside the forwarding directoriescriticalA forward has replaced a privileged socket
    tailscaled listening on a socket in /tmp, /home, /run/user or /rootmediumAn active ssh -R unix forward: confirm who opened it
    Root-owned 0755 socket in /tmp, /home or another user's /run/userhighThe 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.2 with Tailscale SSH on, joined to a headscale 0.29.5 server at 192.0.2.5 whose policy grants only autogroup:nonroot, and 192.0.2.10 with 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.
  4. Step 4:Mitigate across your fleet

    mitigate scans first, then applies the report's interim mitigations on exposed hosts. It removes serve forwards to tailscaled.sock, blocks forwarded tailnet traffic and tailscaled's own TCP to cloud metadata with nftables, installs auditd rules for tailscaled, 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.

    dryRun defaults to true — 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.5

    Pass dryRun=false to apply:

    swamp behavior run tailworm-fleet mitigate \
        --input hosts=192.0.2.10,192.0.2.11,admin@192.0.2.5 \
        --input dryRun=false

    Turning Tailscale SSH off is opt-in and runs last, because it drops every session over Tailscale SSH, including one Swamp connects over. disableAppConnector=true stops advertising the app connector the same way:

    swamp behavior run tailworm-fleet mitigate \
        --input hosts=192.0.2.10 \
        --input dryRun=false \
        --input disableTailscaleSsh=true

    Mitigation needs root, or passwordless sudo for the exact commands the dry run prints. Each runs as sudo -n with no shell wrapper, and every one is checked before anything changes; if one would ask for a password, the host is reported as needs-privilege and 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 --load

    Add each node's own lines from its dry run: one tailscale serve --tcp=<port> off per localapi forward, the headscale steps, and the opt-in tailscale set steps. Without them a node with a forward reports needs-privilege and 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=false

    Then 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 scan

    The 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.

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