top of page

Tailscale Tailworm Flaws: Update to 1.104.4, Hunt SSH Abuse

Writer: Inception Security
Inception Security
3 minutes ago
9 min read

Three-panel comic: Glitch finds a secret tunnel to root, crawls through it, and finds the door already patched to Tailscale 1.104.4 while Inceptor waits with a flashlight.

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 - count

Elastic


FROM logs-*, metrics-*
| WHERE @timestamp > NOW() - 24 hours
| STATS count = COUNT(*) BY data_stream.dataset
| SORT count DESC

CrowdStrike


// 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_ desc

Swap 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, pkg

Elastic


// 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.name

CrowdStrike


// 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, SoftwareName

Match 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_name

Elastic


// 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, FileName

Admin 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, user

Elastic


// 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, ActionType

Dockerd 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 >= 10

Elastic


// 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 >= 10

CrowdStrike


// 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 >= 10

Automation 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



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.


bg-map-white.png

INCEPTION SECURITY™

A cybersecurity company with in depth knowledge of the threat landscape and security controls.

NAVIGATION

GET IN TOUCH

© 2025 All Rights Reserved by INCEPTION SECURITY™ .

bottom of page