SevyDevy

Guide · Backend

Tailscale's October 2026 SSH Vulnerabilities Explained: Forwarding as Root

21 min readUpdated By Saket Bhatnagar

Researched and drafted with AI, reviewed and edited by Saket Bhatnagar.

In short

  • The Tailscale SSH vulnerability of October 2026 let an already allowed peer use SSH forwarding to reach sockets only root should touch. Update tailscaled to 1.104.4 or newer.
  • SSH forwarding sends a connection through your SSH session to a program on the other machine. A Unix socket is a file-like door that local programs talk through.
  • The root cause: the Tailscale daemon runs with the power of root, the all-powerful admin user. It checked only how a socket path was spelled, then acted as root.
  • Tailscale says an attacker needed a foothold on a device your policy already allowed to SSH in. It was not an open door for strangers on the internet.
  • Beginners can stop at the "Beginners can stop here" box after the fix steps. Engineers then get tested demos of path-check mistakes and races, plus an interview answer on privileged daemons.

What goes wrong when one trusted laptop on your tailnet gets malware?

If a server allows Tailscale SSH forwarding and lacks the 1.104.4 fix, that laptop could reach parts of it that only root should touch. This is the Tailscale SSH vulnerability disclosed in October 2026, and the fix is to update.

Picture a common setup. A small team in Pune runs a tailnet, their private Tailscale network, linking laptops and one build server. The setup is made up, but the bug is real.

The build server runs Docker, and the team turned on Tailscale SSH so developers can log in without managing keys. Their access policy allows SSH forwarding, because one developer opens a remote database tool on her laptop.

Then one developer's laptop gets malware. The laptop is still an allowed device, so it can open an SSH session to the build server. Through forwarding, that session could reach the Docker socket, a door that gives root-level control of the server.

The daemon behind this, tailscaled, is a background program with root's power. It acted for the remote user with its own power instead of the user's. The next section gives you the five words to follow the rest.

Which words do you need before reading the Tailscale bulletins?

You need five words: tailnet peer, Tailscale SSH, Unix socket, forwarding and least privilege. Each one connects to the build server.

Tailnet peer. A peer is any device on your tailnet. Your access policy, a file the admin writes, says which peers may talk to which. Example: the infected laptop is a peer allowed to SSH into the build server.

Tailscale SSH. Normally an SSH server checks keys or passwords. With Tailscale SSH, Tailscale decides who may log in using tailnet identity. A port is a numbered door for network connections, and SSH uses port 22. Tailscale's docs say it claims port 22 only for traffic coming from your tailnet.

Unix socket. A Unix socket is a special file that lets programs on the same machine talk. Docker's control API listens on one. On Linux, connecting to it needs write permission on that file, so normal file permissions guard it.

Forwarding. SSH can carry other connections inside the encrypted session. Local forwarding, written ssh -L, sends connections from your machine to a port or Unix socket on the server's side. Remote forwarding, ssh -R, goes the other way.

Least privilege. Each part of a system should have only the power its job needs. That is the rule the bug broke, and every fix below comes back to it.

What is the Tailscale SSH vulnerability of October 2026?

It is a set of four security bulletins, TS-2026-014 to TS-2026-017, dated 26 October 2026 and fixed in Tailscale 1.104.4. The headline one, TS-2026-014, is the Tailscale SSH vulnerability in Unix socket forwarding.

The bulletin says the SSH server in "the root-privileged tailscaled process" checked socket paths "lexically". That means it checked the text of the path, not where the path really led. It then did the work as root.

Tailscale's 28 October post sums up the SSH issues in one line. They "primarily concerned how permissions were enforced after a connection had been authorized". Login worked; what happened after login did not stay inside the user's limits.

Think of tailscaled as a bank clerk holding the master key to every locker. The master key is root's power, the slip is the socket path, and the locker is a socket such as Docker's. The buggy clerk read the slip, saw it looked fine, and used the master key.

Note the two dates. The bulletins are dated 26 October and name 1.104.4 as the fix, while the release itself appeared on 28 October.

The table lists all four bulletins in plain words. If you are a beginner, only the first row is about the bug in this guide. The other rows are about the same release, so the same update fixes them.

The four Tailscale bulletins dated 26 October 2026, all fixed in 1.104.4 or newer
BulletinWhat went wrong, in plain wordsWho was exposedWorst outcome named
TS-2026-014SSH forwarding checked socket paths by spelling, then acted as rootTailscale SSH users whose policy allows local or remote forwardingReach root-only sockets such as Docker's or tailscaled's own control socket
TS-2026-015A logged-in user could make the daemon's memory grow until it ran outNodes running the SSH server; needs a session the policy permitsAn out-of-memory kill of tailscaled, which disrupts every Tailscale connection on the node
TS-2026-016The serve feature, which shares a local service with your tailnet, could also connect to sockets as rootNodes where someone on the machine can change serve settings or the files serve points atFull node takeover through tailscaled's control socket
TS-2026-017Serve could mix up which permissions a web request carriedServe setups with HTTPS and several shared paths with different rulesA peer gets responses from a path it is not allowed to use

Who was actually at risk from the Tailscale SSH bugs?

Someone who already controlled a device your policy allowed to SSH in. Tailscale's post says "Exploitation requires an existing foothold on an authorized peer in the tailnet".

It also says the bugs do not let an unauthenticated attacker connect to arbitrary Tailscale SSH servers. A lost, stolen or infected laptop, or a misused account, is the realistic way in. That still matters, because those devices are trusted by design.

Scope it further with three questions for each machine:

  • Is Tailscale SSH turned on here? Tailscale's docs say the SSH server part runs only on Linux and the open-source macOS command-line build.
  • Does your access policy allow forwarding to it?
  • Do you share anything from it with tailscale serve?

How do you fix the Tailscale SSH vulnerability on your machines?

Update tailscaled to 1.104.4 or newer on every node that runs the Tailscale SSH server or tailscale serve. If you cannot update today, turn off SSH forwarding as a stop-gap.

  1. Find the nodes that matter

    Read the ssh rules in your tailnet policy file. Each rule's dst field names the devices that accept Tailscale SSH. Add any node where you run tailscale serve. The build server is one.

  2. Check the daemon version

    Run tailscale version --daemon on each node. The daemon flag also prints the version of tailscaled, which is the part that was fixed. Look for 1.104.4 or a higher number.

  3. Update

    Update through your package manager, or with tailscale update where your install supports it. On 28 October 2026 the stable package index listed 1.104.4 as latest.

  4. Use the stop-gap only if you must wait

    Set TS_SSH_DISABLE_FORWARDING=1 in the tailscaled service environment and restart tailscaled. Tailscale says it keeps Tailscale SSH logins working. Because it only turns off forwarding, it does nothing for the memory or serve bugs.

  5. Check it worked

    Run tailscale version --daemon again. The fix is in place when it shows 1.104.4 or higher. The version is the signal to trust, since Tailscale documents no test for the stop-gap.

  6. Tighten the policy

    Allow SSH only from the users and devices that need it. Consider check mode, which asks users to log in again; Tailscale's docs give a default check period of 12 hours.

Tailscale's investigation page, last updated 27 October, gives the stop-gap but names no file for the service environment. On many Linux installs tailscaled runs under systemd, the program that starts and supervises background services, so check how your package starts it before editing anything.

Remove the stop-gap once every node runs 1.104.4 or newer, if your team needs forwarding. If nobody uses forwarding, keeping it off is good least privilege.

How did Tailscale SSH forwarding reach root-only sockets?

The daemon did the forwarding work itself, as root, after a check that only looked at the path's text. The bulletin names three ways this went wrong.

  • "Privileged dials via local forwarding": a dial is opening a connection. tailscaled connected to the socket with root's permissions, not the logged-in user's.
  • "Socket replacement via remote forwarding (ssh -R) by unlinking root-owned sockets": unlinking deletes a file, so a socket root owned could be removed and replaced.
  • A race beat the path allowlist, the list of allowed paths, "because the kernel re-resolves paths after the initial check." The next section shows this race.

The diagram follows the build server. Notice where the permission check sits. It looks at a name, while the connection that matters happens later and with more power.

How a forwarding request became a root-level connection

The check and the connection are separate steps, and the connection uses root's power. That gap is the whole bug.

Diagram connections:

  • Infected laptop to Tailnet access policy (asks to SSH in)
  • Tailnet access policy to tailscaled as root (allowed)
  • tailscaled as root to Path text check (forwarding request)
  • Path text check to Connect as root (looks fine)
  • Connect as root to Docker socket (connects)
  • Infected laptop: An authorised peer with SSH allowed
  • Tailnet access policy: Allows SSH and forwarding to the server
  • tailscaled as root: Runs the SSH server on the build server
  • Path text check: Looks at the spelling of the socket path
  • Connect as root: Uses root permissions, not the user's
  • Docker socket: Root-level control of the build server

Why the Docker socket matters

Docker's own security docs say the Docker daemon needs root privileges unless you use rootless mode. They also say "only trusted users should be allowed to control your Docker daemon."

The reason is simple. Whoever controls Docker can start a container with the host's whole disk mounted inside it. Docker's docs say such a container can alter the host filesystem without any restriction. So reaching that socket is close to being root.

TS-2026-014 also names the tailscaled local API, the daemon's own control socket on the machine. How bad that is shows in a separate bulletin about serve: TS-2026-016 says reaching it allowed "full node takeover".

Back to the bank clerk

In the clerk picture from earlier, the safe clerk opens only lockers the customer owns. The buggy clerk trusted the slip. A clever slip, or a locker label swapped while the clerk walked over, led to someone else's locker.

Where the picture breaks: a clerk can see the locker, but a program sees only a path. On Linux, the path is looked up again every time it is used, which is why a later swap works.

What is a time-of-check to time-of-use race?

A time-of-check to time-of-use race, or TOCTOU, happens when a program checks something, then uses it later, and it changes in between. The check was true, but stale by the time it mattered.

With files and sockets, the usual trick is a symbolic link, or symlink. A symlink is a small file that points to another path, like a shortcut. The kernel follows it every time the path is used.

We ran a harmless demo on plain files in a temporary folder. It touches no sockets and nothing from Tailscale. It shows the general bug classes, not Tailscale's code or Tailscale's fix.

python
"""Harmless demo of two general bug classes: path checks that read only the text, and
time-of-check to time-of-use (TOCTOU). Plain files in a new temp folder; no sockets, no Tailscale.
Tested on CPython 3.13.18, Linux, run as root (part 5 makes a hard link). Run: python3 path_check_demo.py"""
import os
import pwd
import tempfile

root = tempfile.mkdtemp()
allowed = os.path.join(root, "allowed")
private = os.path.join(root, "private")
os.mkdir(allowed)
os.mkdir(private)
secret = os.path.join(private, "secret.txt")
with open(secret, "w") as f:
    f.write("ROOT-ONLY DATA")


def text_ok(path):  # checks only how the path is spelled
    return os.path.normpath(path).startswith(allowed + os.sep)


def resolved_ok(path):  # follows symlinks first, then checks
    return os.path.realpath(path).startswith(allowed + os.sep)


# Part 1: a symlink inside allowed/ that points to private/
link = os.path.join(allowed, "app.txt")
os.symlink(secret, link)
print("1. text check passes:", text_ok(link))
print("1. resolve-then-check passes:", resolved_ok(link))

# Part 2: TOCTOU. Safe file at check time, symlink at use time (swap staged in order)
target = os.path.join(allowed, "data.txt")
with open(target, "w") as f:
    f.write("harmless")
print("2. check passes:", resolved_ok(target))
os.remove(target)
os.symlink(secret, target)
with open(target) as f:
    print("2. later open reads:", f.read())

# Part 3: O_NOFOLLOW refuses a symlink only at the LAST part of the path
try:
    os.open(target, os.O_RDONLY | os.O_NOFOLLOW)
except OSError as e:
    print("3. last part is a symlink:", e.strerror)
os.symlink(private, os.path.join(allowed, "sub"))  # a symlinked folder
fd = os.open(os.path.join(allowed, "sub", "secret.txt"), os.O_RDONLY | os.O_NOFOLLOW)
print("3. folder symlink still followed:", os.read(fd, 100).decode())


# Part 4: check the object you actually opened (Linux: ask /proc where the descriptor points)
def opened_ok(fd):
    real = os.readlink(f"/proc/self/fd/{fd}")
    return real.startswith(allowed + os.sep)


print("4. check on the opened file passes:", opened_ok(fd))
os.close(fd)

# Part 5: a hard link is a second name for the same file, so name checks all pass.
# (Creating it needs root here; Linux fs.protected_hardlinks stops most other users.)
hard = os.path.join(allowed, "hard.txt")
os.link(secret, hard)
fd = os.open(hard, os.O_RDONLY | os.O_NOFOLLOW)
print("5. resolve-then-check passes:", resolved_ok(hard))
print("5. check on the opened file passes:", opened_ok(fd))
print("5. reads:", os.read(fd, 100).decode())


# A check that does not depend on names: who owns the opened file?
def owner_ok(fd, requester_uid):
    return os.fstat(fd).st_uid == requester_uid


print("5. owner check for an unprivileged requester passes:", owner_ok(fd, pwd.getpwnam("nobody").pw_uid))
os.close(fd)

Pause and predict before reading the output: in which parts does the private file get through, and which guard finally stops it? Run as root on CPython 3.13.18 on Linux, it printed exactly this:

text
1. text check passes: True
1. resolve-then-check passes: False
2. check passes: True
2. later open reads: ROOT-ONLY DATA
3. last part is a symlink: Too many levels of symbolic links
3. folder symlink still followed: ROOT-ONLY DATA
4. check on the opened file passes: False
5. resolve-then-check passes: True
5. check on the opened file passes: True
5. reads: ROOT-ONLY DATA
5. owner check for an unprivileged requester passes: False

What the output shows

Part 1 is the text mistake. The path starts with the allowed folder, so the text check says yes, but it really points to the private file. Resolving the path first, with os.path.realpath, catches it.

Part 2 is TOCTOU. The resolve-then-check passes, then the file is swapped for a symlink before the open. We staged the swap in order; in real life another process does it at the same moment.

Part 3 shows the limit of O_NOFOLLOW, a flag that tells Linux to refuse an open if the last part of the path is a symlink. A symlinked folder earlier in the path still gets through, as the open(2) manual says.

Part 4 checks the name of the file that was actually opened. On Linux, /proc/self/fd shows the name an open file descriptor points to. If the path is swapped after the open, the descriptor still reads the same file it opened. The name /proc shows is that file's current name, so it follows renames.

Part 5 is a hard link, a second name for the same file. Every name check passes, because the name really is inside the allowed folder. Checking who owns the opened file, with os.fstat, does not depend on names and rejects it.

The owner check is only a demo guard. Real permissions also allow access through groups, which an owner check refuses. Acting as the requesting user, shown next, is the dependable guard.

Linux's fs.protected_hardlinks setting, switched on on our test machine, stops users from linking to files they do not own or cannot read and write. Our demo made the link as root.

Five ways to guard a file path, as run in our demo on CPython 3.13.18
ApproachWhat fooled it in our demoWhat it caught
Check the path's text onlyA symlink inside the allowed folder (part 1)Nothing; we only tried it in part 1
Resolve the path, then checkA swap after the check (part 2) and a hard link (part 5)The planted symlink (part 1)
Open with O_NOFOLLOWA symlinked folder earlier in the path (part 3)A symlink as the last part of the path (part 3)
Check the name of the opened fileA hard link, which is a real second name (part 5)The symlinked folder (part 4)
Check who owns the opened fileNothing in this demo, but it is stricter than real permissions: it refuses files shared through a groupThe hard link to root's file (part 5)

A TOCTOU race in four steps

Step 2 happens between the check and the use. Any check that is not tied to the opened object can be fooled this way.

Diagram connections:

  • 1. Check the path to 2. Path is swapped (check passes)
  • 2. Path is swapped to 3. Open the path (later)
  • 3. Open the path to 4. Wrong target reached (follows symlink)
  • 1. Check the path: Path points to a harmless file in allowed/
  • 2. Path is swapped: Another process puts a symlink there
  • 3. Open the path: Kernel looks up the path again
  • 4. Wrong target reached: The open follows the symlink to private/

Why is a root daemon acting for a remote user so risky?

Because every check it forgets is done with root's power. The Linux capabilities manual says privileged processes "bypass all kernel permission checks". This pattern has a name: the confused deputy.

A confused deputy is a program with more authority than its caller, tricked into using that authority for the caller. tailscaled is the deputy. The SSH peer is the caller. The Docker socket is what the deputy should not have reached.

Sockets make path tricks harder to block. You reach a Unix socket with connect(), and connect() takes only a socket and an address, with no flag like O_NOFOLLOW. So for sockets, the simplest dependable guard is acting with the requesting user's permissions.

We tested that with a second harmless script. It creates its own socket in a temporary folder, with mode 600, meaning only the owner, root, may connect. Then it drops privileges in a child process: it clears root's extra groups and switches to nobody, a standard Linux account with almost no rights.

python
"""Harmless demo with a Unix socket we create ourselves in a new temp folder.
Shows: connect() follows a symlink to a socket, and a 0600 root-owned socket refuses an
unprivileged user. Needs to start as root. Tested on CPython 3.13.18, Linux."""
import os
import pwd
import socket
import tempfile

folder = tempfile.mkdtemp()
os.chmod(folder, 0o755)
sock_path = os.path.join(folder, "root-only.sock")
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(sock_path)
server.listen()
os.chmod(sock_path, 0o600)  # only root may connect

link = os.path.join(folder, "looks-harmless.sock")
os.symlink(sock_path, link)
c = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
c.connect(link)  # connect() takes a path; there is no "do not follow symlinks" flag
print("root connect via symlink: connected", flush=True)
c.close()

pid = os.fork()
if pid == 0:
    nobody = pwd.getpwnam("nobody")
    os.setgroups([])  # drop root's extra groups first, or they survive the switch
    os.setgid(nobody.pw_gid)
    os.setuid(nobody.pw_uid)
    c = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    try:
        c.connect(sock_path)
        print("nobody connect: connected", flush=True)
    except PermissionError as e:
        print("nobody connect:", e.strerror, flush=True)
    os._exit(0)
os.waitpid(pid, 0)
server.close()

Started as root on CPython 3.13.18 on Linux, it printed:

text
root connect via symlink: connected
nobody connect: Permission denied

The first line shows connect() following a symlink without complaint. The second shows the kernel refusing the less powerful user, at the moment of use, with no path logic to get wrong.

That is the route Tailscale's own fix takes. TS-2026-014 says: "Tailscale now performs Unix socket forwarding on behalf of the authenticated user rather than as root".

TS-2026-015 teaches a different lesson. Per-session memory had no limit and stayed after the peer disconnected. A daemon that serves many users needs caps per session and cleanup when the session ends. The bulletin says Tailscale now "bounds the memory used for session handling and releases it when the peer disconnects".

What else did Tailscale fix in its serve feature?

Two serve bugs were fixed in the same release. They matter if you share local services with tailscale serve, even without Tailscale SSH.

TS-2026-016 is the same root-deputy mistake. Serve's TCP forwards to Unix sockets "lacked a guard and dialed the target as root". Its symlink check for web proxies ran only when the config reloaded, not on every connection, which is a TOCTOU gap. The fix, per the bulletin, "revalidates backend targets during each dial".

TS-2026-017 is a different race. Serve can send a header called Tailscale-App-Capabilities, which tells your app what the visiting peer is allowed to do. On HTTP/2, many requests share one connection, and they shared one serve context.

So the header meant for one shared path, which the bulletin calls a mount, could reach another. Only setups with HTTPS and two or more mounts with different rules were exposed.

Tailscale's post says the report had five findings about serve and Funnel, its public-internet sharing feature: three bugs fixed in 1.104.4 and two that work as designed. An issue in Conn25, an unreleased next generation of app connectors, does not affect the stable ones.

What happened between 23 and 28 October 2026?

The fix shipped within about four days of Tailscale receiving the report. Here is the order, from Tailscale's post, its bulletins, its investigation page and its GitHub releases page.

Timeline from Tailscale's 28 October post, bulletins, investigation page and releases page
Date (2026)What happened
Friday 23 OctoberConcerns about Tailscale SSH began circulating on social media
Saturday 24 OctoberTailscale received the researcher's report in the morning; the researcher published it two hours later
Monday 26 OctoberInvestigation update with initial assessment and precautions; bulletins TS-2026-014 to 017 dated this day
Tuesday 27 OctoberInvestigation page, updated this day, gives the forwarding stop-gap and targets a release by the end of the day
Wednesday 28 Octoberv1.104.4 marked Latest on GitHub at 01:05 (the page shows no timezone); post "Tailscale SSH: Findings and fixes" published

Some details are still thin, as of 28 October 2026. The GitHub release notes only point to the changelog, and the changelog had no 1.104.4 entry yet. The investigation page still said the patched release was not yet available. No CVE IDs were listed. Watch the bulletins page for changes.

How do you explain the Tailscale SSH vulnerability in a system design interview?

Explain it as a confused-deputy design flaw, then show how you would design the deputy safely. Interviewers want the pattern, not the bulletin numbers.

  1. Draw the trust boundary

    Sketch the remote user, the root daemon and the local resources. Mark that authentication happened at the edge, but every later action crosses a boundary again.

  2. Name the pattern

    Say "confused deputy": a privileged program doing work for a less privileged caller. Point to the forwarding request as the moment authority leaks.

  3. Show the two check mistakes

    Explain text checks versus real targets, then TOCTOU. Mention symlinks and that the kernel resolves the path again at use.

  4. Offer layered fixes

    Do the work as the user, so the kernel checks permissions. Split privileges so only a tiny part runs as root. Check the opened object, not the name.

  5. Bound the resources

    Cap memory per session and free it on disconnect, as TS-2026-015 shows. One user should never be able to starve a shared daemon.

  6. Add defence in depth

    Turn off features nobody uses, like forwarding. Keep access policies tight, re-authenticate for sensitive hosts, and plan how you ship a fix fast.

A good closing line: authentication answers who you are, but every later action still needs authorisation with your identity. Add that each user's resources need limits, and you have covered all four bulletins.

Questions people ask

Is the Tailscale SSH vulnerability fixed?
Yes. Tailscale says all four bulletins, TS-2026-014 to TS-2026-017, are fixed in version 1.104.4 or newer. That release was marked Latest on GitHub and listed on the stable package index on 28 October 2026. Check each node with tailscale version --daemon, because the daemon is the part that matters.
Could a stranger on the internet exploit this?
Not according to Tailscale. Its post says exploitation requires an existing foothold on an authorised peer in the tailnet. It also says the bugs do not let an unauthenticated attacker connect to arbitrary Tailscale SSH servers. A stolen or infected laptop that your policy already trusts is the realistic risk.
Do I need to update if I do not use Tailscale SSH?
Updating is still wise, but your exposure is lower. The two SSH bulletins need the Tailscale SSH server running. The two serve bulletins need tailscale serve with specific setups, such as Unix socket backends or HTTPS with several mounts. Updating removes the need to work out which case you are in.
What does TS_SSH_DISABLE_FORWARDING=1 do?
It turns off SSH forwarding in tailscaled while keeping Tailscale SSH logins working. Tailscale gives it as a stop-gap for the forwarding bug. You set it in the tailscaled service environment and restart the service. It is not a replacement for updating, because the other bulletins are not about forwarding.
Is there a CVE number for the Tailscale SSH bugs?
Not as of 28 October 2026. The four Tailscale bulletins list no CVE IDs and no severity scores. Track them by their Tailscale IDs, TS-2026-014 to TS-2026-017, and check the bulletins page again later in case CVE IDs are added.
Was this a bug in OpenSSH?
No. Tailscale SSH is the SSH server built into the tailscaled process, not OpenSSH. The bugs were in how tailscaled handled forwarding, sessions and serve backends. Your normal SSH client still connects to it, which is why ssh -L and ssh -R requests reach this code.
What is the general lesson for my own code?
Never let a privileged process act on a path or resource just because its name looks safe. Do the work as the requesting user when you can, check the object you actually opened, and limit memory per user. These habits prevent both confused-deputy bugs and TOCTOU races.

Sources and further reading

Practise this with Lala

Practise