Tailscale Tailworm Flaws: Update to 1.104.4, Hunt SSH Abuse


What this means for your clients: if one laptop or web server on their private Tailscale network is taken over, the remote access rules they set for convenience may decide how far that foothold spreads, so update every device.
Picture a client tailnet with a build box that runs Docker and a contractor laptop that can SSH into it. The laptop picks up an infostealer. Until this week, that one SSH rule with forwarding was enough, according to the researcher, to reach the build box's root-only Docker socket and run as root. Nobody typed a password on the build box, per the researcher's lab.
That is the Tailscale Tailworm report, published by Erica Windisch of Cyberdione Labs, and the fix is out. It is a Tailscale vulnerability in how Tailscale SSH handled Unix socket forwarding as root, with related Tailscale flaws in serve and session handling. Tailscale shipped 1.104.4 with TS-2026-014 through TS-2026-017. Update every node, then hunt; your tailnet's SSH rules decide who can reach whom.
What the Tailscale Tailworm flaws actually do
The serious class sits in Tailscale SSH, the SSH server built into tailscaled, which runs as root. TS-2026-014 says it checked Unix socket paths by name but performed the forwarding as root, so a session allowed to forward could dial root-only sockets, replace root-owned sockets, or race the path allowlist. Tailscale names the Docker socket and the tailscaled local API as targets, and says the fix forwards as the authenticated user.
Companion bulletins, each fixed in 1.104.4 or newer:
TS-2026-015: memory exhaustion in Tailscale SSH session handling that can OOM-kill tailscaled
TS-2026-016: tailscale serve Unix backends reaching privileged sockets, including full node takeover through the local API per the bulletin
TS-2026-017: HTTP/2 race that can bleed Tailscale-App-Capabilities across mounts
No CVE ID and no CVSS appear on the bulletins or Tailscale's write-up.
Researcher claims, Tailscale's pushback, and what is still open
Cyberdione Labs reports 11 findings on Tailscale v1.104.2 and headscale v0.29.5, lab-verified between October 22 and 24. The researcher claims the SSH chain can hop automatically in the lab at under a second per hop with no user interaction, and that headscale auto-grants forwarding on every accepted session.
Mike Shaver's blog says exploitation needs an existing foothold on an authorized peer with SSH explicitly permitted in the tailnet policy file, and that the issues are not a worm because lateral movement stays limited to connections that policy already allows. Of five Serve and Funnel findings, Tailscale says three were bugs fixed in 1.104.4 and two are by design. SecurityWeek says neither side mentions exploitation, and the bulletins do not either, so no exploitation is reported.
The bulletins map the fix to TS-2026-014 and the three companions, not to all 11 findings. Tailscale says Conn25 is still pre-production and unreleased, and today's stable connectors are not affected. The researcher's mitigation table marks the DERP client fix as shipped and the headscale fix as pending, though Tailscale's bulletins do not mention DERP. Neither source lists affected operating systems or says how many tailnets are exposed.
Update, then harden the policy that set the blast radius
Verify with tailscale version, then move every node to Tailscale 1.104.4 or newer. Where you cannot update yet, Tailscale's mitigation for TS-2026-014 is to set TS_SSH_DISABLE_FORWARDING=1 in the tailscaled service environment and restart, which keeps Tailscale SSH on without forwarding.
After the bump, treat the tailnet policy file as the thing that decided who could reach whom. Narrow SSH rules to named users and devices, drop forwarding where unneeded, and put check mode on sensitive systems, because the patch does not rewrite those rules.
Review serve configs that point at a Unix socket near the Docker socket or the local API. The researcher's advice to disable experimental app connectors and block 169.254.169.254 from connector hosts is researcher-derived; apply it if those features are in use.
Self-hosted headscale shops need a separate look. The latest headscale release we can confirm is v0.29.6 from October 24, and its notes do not claim a Tailworm fix. The headscale fix is unconfirmed; follow the project and limit internet exposure of the control server.
Hunts to run (test first)
These hunts look for nodes still below the fixed build and for patterns that would matter if an approved peer abused SSH forwarding. They find evidence, not proof. The table under the hunts, "Interpret before you escalate", says what is benign and what is not. Field names for Tailscale logs and the tailscaled process name in each SIEM are assumed. Many SMBs never ingest Tailscale logs. Match Elastic's time window on the other platforms.
Test query. Confirm endpoint process data, and any Tailscale stream you think you have, reach the index. Empty means the source is missing or the range is wrong.
Splunk
index=* (sourcetype=linux:process OR sourcetype=XmlWinEventLog:Microsoft-Windows-Sysmon/Operational OR sourcetype=crowdstrike:events OR sourcetype=tailscale:*) earliest=-24h
| stats count by index, sourcetype
| sort - countElastic
FROM logs-*, metrics-*
| WHERE @timestamp > NOW() - 24 hours
| STATS count = COUNT(*) BY data_stream.dataset
| SORT count DESCCrowdStrike
// Time picker: last 24 hours
(#event_simpleName=ProcessRollup2) OR (#Vendor=/tailscale/i)
| groupBy([#event_simpleName, #Vendor], function=count(as=count))
| sort(count, order=desc)Microsoft KQL
union isfuzzy=true withsource=SourceTable DeviceProcessEvents, DeviceTvmSoftwareInventory, DeviceNetworkEvents
| where isnull(Timestamp) or Timestamp > ago(24h)
| summarize count() by SourceTable
| order by count_ descSwap in your Tailscale table if you stream network flow logs into Sentinel; Tailscale documents that stream on Premium and Enterprise plans.
Hunt 1: inventory below Tailscale 1.104.4. Needs software inventory or package telemetry. String compare on "1.104.4" is unsafe, so split into numbers and keep hosts below (1, 104, 4).
Splunk
index=* (sourcetype=package OR sourcetype=*:inventory OR sourcetype=linux:software) earliest=-7d
(product_name="*Tailscale*" OR name="*tailscale*" OR package="*tailscale*")
| rex field=version "(?<vmaj>\d+)\.(?<vmin>\d+)\.(?<vpatch>\d+)"
| eval vmaj=tonumber(vmaj), vmin=tonumber(vmin), vpatch=tonumber(vpatch)
| where isnotnull(vmaj) AND (vmaj < 1 OR (vmaj=1 AND vmin < 104) OR (vmaj=1 AND vmin=104 AND vpatch < 4))
| eval pkg=coalesce(product_name, name, package, "unknown")
| stats values(version) as versions, latest(_time) as last_seen by host, pkgElastic
// ES|QL; field names assumed
FROM logs-package.*, metrics-system.software*
| WHERE @timestamp > NOW() - 7 days
AND (package.name LIKE "*tailscale*" OR package.name LIKE "*Tailscale*")
| GROK package.version "%{INT:vmaj:int}\\.%{INT:vmin:int}\\.%{INT:vpatch:int}"
| WHERE vmaj < 1 OR (vmaj == 1 AND vmin < 104) OR (vmaj == 1 AND vmin == 104 AND vpatch < 4)
| STATS versions = VALUES(package.version), last_seen = MAX(@timestamp) BY host.name, package.nameCrowdStrike
// Time picker: last 7 days; ProductName/ProductVersion assumed
#event_simpleName=InstalledApplication
| ProductName=/tailscale/i
| ProductVersion=/\d+\.\d+\.\d+/
| ProductVersion=/^(?<vmaj>\d+)\.(?<vmin>\d+)\.(?<vpatch>\d+)/
| test(vmaj < 1 or (vmaj == 1 and vmin < 104) or (vmaj == 1 and vmin == 104 and vpatch < 4))
| groupBy([aid, ComputerName, ProductName], function=[collect(ProductVersion), max(@timestamp, as=last_seen)])Microsoft KQL
DeviceTvmSoftwareInventory
| where SoftwareName has_any ("Tailscale", "tailscale")
| extend vmaj = toint(extract(@"^(\d+)\.", 1, SoftwareVersion)), vmin = toint(extract(@"^\d+\.(\d+)", 1, SoftwareVersion)), vpatch = toint(extract(@"^\d+\.\d+\.(\d+)", 1, SoftwareVersion))
| where isnotnull(vmaj) and (vmaj < 1 or (vmaj == 1 and vmin < 104) or (vmaj == 1 and vmin == 104 and vpatch < 4))
| summarize versions = make_set(SoftwareVersion, 10) by DeviceName, SoftwareNameMatch both tailscale and Tailscale package names. A host with no reported version is a visibility gap, not a clean host.
Hunt 2: root-context children of tailscaled. Needs Linux process telemetry (Sysmon for Linux, auditd, Falcon ProcessRollup2, or MDE DeviceProcessEvents). Flag root children of tailscaled with docker, curl to a local socket, or destructive file tools. We picked that command list, so tune it to your fleet.
Splunk
index=* (sourcetype=linux:sysmon OR sourcetype=linux:audit OR sourcetype=sysmon:linux) earliest=-7d
(parent_process_name=tailscaled OR parent_process_name="tailscale ssh" OR ParentImage="*tailscaled*")
| where user="root" OR User="root" OR uid=0
| eval cmd=coalesce(CommandLine, process)
| regex cmd="(?i)(docker|curl|wget|rm\s|chmod|systemctl|/var/run/docker\.sock|tailscaled\.sock)"
| fillnull value=unknown user parent_process_name process_name
| stats count, values(cmd) as cmds, min(_time) as first_seen by host, user, parent_process_name, process_nameElastic
// EQL; process names assumed
process where user.id == "0"
and process.parent.name in ("tailscaled", "tailscale")
and process.command_line regex~ ".*(docker|curl|wget|rm |chmod|systemctl|docker\\.sock|tailscaled\\.sock).*"CrowdStrike
// Time picker: last 7 days
#event_simpleName=ProcessRollup2
| ParentBaseFileName=/^(tailscaled|tailscale)$/i
| UserName=/^root$/i or UserSid="S-1-5-18" or uid=0
| CommandLine=/(docker|curl|wget|\brm\b|chmod|systemctl|docker\.sock|tailscaled\.sock)/i
| groupBy([ComputerName, UserName, ParentBaseFileName, FileName], function=[count(as=count), collect(CommandLine), min(@timestamp, as=first_seen)])Microsoft KQL
DeviceProcessEvents
| where Timestamp > ago(7d)
| where InitiatingProcessFileName in~ ("tailscaled", "tailscaled.exe", "tailscale", "tailscale.exe")
| where AccountName =~ "root" or ProcessIntegrityLevel =~ "System"
| where ProcessCommandLine matches regex @"(?i)(docker|curl|wget|\brm\b|chmod|systemctl|docker\.sock|tailscaled\.sock)"
| summarize count(), cmds = make_set(ProcessCommandLine, 20), first_seen = min(Timestamp)
by DeviceName, AccountName, InitiatingProcessFileName, FileNameAdmin work inside a Tailscale SSH session hits this too, so treat a known admin as a review, not an automatic page.
Hunt 3: Docker socket or tailscaled socket touch. Needs file or audit events on /var/run/docker.sock and the local API socket. Falcon and MDE Unix socket coverage is uneven; treat empty as a collection gap until proven otherwise.
Splunk
index=* (sourcetype=linux:audit OR sourcetype=linux:sysmon OR sourcetype=sysmon:linux) earliest=-7d
(file_path="*/docker.sock" OR file_path="*/tailscaled.sock" OR ObjectName="*/docker.sock" OR ObjectName="*/tailscaled.sock")
| where process_name="tailscaled" OR parent_process_name="tailscaled" OR process_name="ssh" OR process_name="sshd"
| eval path=coalesce(file_path, ObjectName)
| fillnull value=unknown user
| stats count, values(path) as paths, values(process_name) as procs, min(_time) as first_seen by host, userElastic
// EQL; paths assumed
file where (file.path in ("/var/run/docker.sock", "/run/docker.sock")
or file.path like "*tailscaled.sock")
and process.name in ("tailscaled", "ssh", "sshd")CrowdStrike
// Time picker: last 7 days; event names assumed; Unix socket visibility varies
#event_simpleName=/^(FileOpen|FileOpenRead|FileOpenWrite|FileConnect)$/
| (TargetFileName=/docker\.sock$/i or TargetFileName=/tailscaled\.sock$/i)
| (ContextBaseFileName=/^(tailscaled|ssh|sshd)$/i or ParentBaseFileName=/^tailscaled$/i)
| groupBy([ComputerName, TargetFileName, ContextBaseFileName], function=[count(as=count), min(@timestamp, as=first_seen)])Microsoft KQL
DeviceFileEvents
| where Timestamp > ago(7d)
| where FolderPath has_any ("docker.sock", "tailscaled.sock")
or FileName has_any ("docker.sock", "tailscaled.sock")
| where InitiatingProcessFileName in~ ("tailscaled", "ssh", "sshd", "tailscale")
or InitiatingProcessParentFileName in~ ("tailscaled", "tailscale")
| summarize count(), first_seen = min(Timestamp), paths = make_set(FolderPath, 10)
by DeviceName, InitiatingProcessFileName, FileName, ActionTypeDockerd opens its socket all day, so require the initiator to be tailscaled or an SSH binary.
Hunt 4: tailnet fan-out from one CGNAT IP. Needs Tailscale network flow logs (Premium and Enterprise only) with sources in 100.64.0.0/10. Many SMBs lack those logs. It counts destinations on any protocol, not just SSH, and we set the bar at 10 destinations in 15 minutes.
Splunk
index=* (sourcetype=tailscale:networkflow OR sourcetype=tailscale:*) earliest=-7d
| spath
| eval src=mvindex('srcNode.addresses{}', 0)
| where cidrmatch("100.64.0.0/10", src)
| eval dst_host=mvfilter(match('dstNodes{}.addresses{}', "^100\."))
| mvexpand dst_host
| bin _time span=15m
| stats dc(dst_host) as dest_count, values(dst_host) as dests by src, _time
| where dest_count >= 10Elastic
// ES|QL; field names assumed from Tailscale src/dst docs (KB 1219)
FROM logs-tailscale.network*
| WHERE @timestamp > NOW() - 7 days
AND CIDR_MATCH(src.ip, "100.64.0.0/10")
AND CIDR_MATCH(dst.ip, "100.64.0.0/10")
| EVAL bucket = DATE_TRUNC(15 minutes, @timestamp)
| STATS dest_count = COUNT_DISTINCT(dst.ip), dests = VALUES(dst.ip) BY src.ip, bucket
| WHERE dest_count >= 10CrowdStrike
// Time picker: last 7 days. Assumes Tailscale flow logs in Falcon Next-Gen SIEM
#Vendor=tailscale
| src_ip := replace(field=src, regex=":\\d+$", with="")
| src_ip=/^100\.(6[4-9]|[7-9][0-9]|1[0-1][0-9]|12[0-7])\./
| dst_ip := replace(field=dst, regex=":\\d+$", with="")
| dst_ip=/^100\.(6[4-9]|[7-9][0-9]|1[0-1][0-9]|12[0-7])\./
| bucket(span=15m, field=src_ip, function=[count(dst_ip, distinct=true, as=dest_count), collect(dst_ip)])
| test(dest_count >= 10)Microsoft KQL
// Assumed custom table/columns from a Tailscale flow stream; swap for yours
TailscaleNetworkFlow_CL
| where TimeGenerated > ago(7d)
| where ipv4_is_in_range(SrcIP, "100.64.0.0/10") and ipv4_is_in_range(DstIP, "100.64.0.0/10")
| summarize dest_count = dcount(DstIP), dests = make_set(DstIP, 50) by SrcIP, bin(TimeGenerated, 15m)
| where dest_count >= 10Automation and a busy admin will cross ten destinations, so pair this with Hunt 2 before calling it abuse. Without flow logs, manually review sshd Accepted lines from 100.64.0.0/10 over the same window, knowing that Tailscale SSH sessions run inside tailscaled and will not appear there; no SIEM query for that check is included here.
Also check Tailscale configuration audit logs for sudden SSH policy or serve changes, and connector hosts reaching 169.254.169.254.
Interpret before you escalate
Finding | What it likely means | Next step |
Host on Tailscale below 1.104.4 | Patch debt, not proof of abuse | Schedule the update, apply the forwarding mitigation until then |
Root child of tailscaled running docker or curl to a socket | Possible forwarding abuse, or a root SSH session doing work | Match the parent session to the policy rule and the user |
tailscaled opening docker.sock | Suspicious on servers that should not expose the socket over SSH | Confirm whether Serve or SSH forwarding is enabled on that node |
Tailnet fan-out from one 100.64 address to many hosts | Could be automation, or a hop chain | Correlate with Hunt 2 and the SSH policy for that source |
Empty Hunt 3 or 4 | Often missing Unix socket telemetry or no flow logs | Fix collection before you trust a clean result |
headscale control plane only | Client fix does not equal a control-server fix | Track the headscale project; do not claim a fix yet |
What to alert on
Hunt 1 is a standing hygiene alert until the fleet is current. Hunt 2 on docker or local-socket tools parented by tailscaled as root deserves high priority review as possible follow-on root activity, though forwarding itself runs inside tailscaled and may spawn no child, so Hunt 3 is the closer signal for TS-2026-014; page only when the account is not an expected admin. Sweep Hunts 3 and 4 on a schedule.
What This Means for Your Team
For an MSP, the client's convenience rules wrote who could reach whom. One approved device plus one SSH rule with forwarding was the researcher's entry condition, and Tailscale's fix does not rewrite those rules. Ask which nodes still run below Tailscale 1.104.4, which SSH rules still allow forwarding, and whether anyone streams network flow logs.
Sources
Tailscale Security Bulletins TS-2026-014, TS-2026-015, TS-2026-016, TS-2026-017: https://tailscale.com/security-bulletins
Mike Shaver, Tailscale SSH: Findings and fixes (Oct 28, 2026): https://tailscale.com/blog/tailscale-ssh-findings-fixes
Erica Windisch, Cyberdione Labs, Tailworm report: https://www.tailworm.com/
Tailscale v1.104.4 GitHub release: https://github.com/tailscale/tailscale/releases/tag/v1.104.4
Tailscale network flow logs: https://tailscale.com/kb/1219/network-flow-logs
Tailscale log streaming (Premium and Enterprise): https://tailscale.com/kb/1255/log-streaming
headscale releases: https://github.com/juanfont/headscale/releases
Eduard Kovacs, SecurityWeek, Tailscale rushes patches after uncoordinated disclosure of Tailworm flaws (Oct 27, 2026): https://www.securityweek.com/tailscale-rushes-patches-after-uncoordinated-disclosure-of-tailworm-flaws/
Inception Protection
Inception Protection is the Inception Security managed detection and response service, and it works with the Microsoft licenses you already have. We watch Defender endpoint and Entra sign-in data, where a stolen laptop that later abuses a mesh SSH rule often shows first, and work through containment with your team.
Inception Foresight
Tailworm starts with a foothold on an approved device. To see whether your Microsoft environment would notice that laptop, try the free Inception Foresight M365 Assessment: https://www.inceptionsecurity.com/m365assessment.



