Report 2: Tailscale SSH local TCP forwarding dials arbitrary destinations as root (no PermitOpen equivalent)

2026/10/24

Unrestricted root-dialed TCP forwarding (direct-tcpip)

Severity (my assessment): Medium-High — context-dependent; reaches every localhost-bound or network-restricted service from an authorized non-root SSH session. Date found: 2026-10-24 · Reporter: psn · shared privately with Tailscale 2026-10-24

Summary

Once an SSH action allows local port forwarding, tailscaled (running as root) will connect() to any host:port the SSH client names — the destination is never checked. Any SSH-authorized non-root user can therefore reach services that rely on “only root / only localhost can connect to me”: Docker’s TCP API bound to 127.0.0.1, etcd, kubelet ports, node exporters, databases that trust local peer credentials, internal admin panels — and can pivot to arbitrary network targets as the host’s root user, with connections sourced from the machine itself.

Affected versions

Technical detail

ssh/tailssh/tailssh.go:

func (c *conn) mayForwardLocalPortTo(ctx gliderssh.Context, destinationHost string, destinationPort uint32) bool {
	if sshDisableForwarding() { return false }
	if c.finalAction != nil && c.finalAction.AllowLocalPortForwarding {
		return true   // destinationHost / destinationPort are ignored
	}
	return false
}

gliderssh tcpip.go:

if srv.LocalPortForwardingCallback == nil || !srv.LocalPortForwardingCallback(ctx, d.DestAddr, d.DestPort) {
	newChan.Reject(gossh.Prohibited, "port forwarding is disabled")
	return
}
dest := net.JoinHostPort(d.DestAddr, strconv.FormatInt(int64(d.DestPort), 10))
var dialer net.Dialer
dconn, err := dialer.DialContext(ctx, "tcp", dest)   // performed by the root tailscaled process

There is no destination allowlist, no loopback/metadata-range exclusion, and no policy knob equivalent to OpenSSH’s PermitOpen/DisableForwarding (the only switch is the global TS_SSH_DISABLE_FORWARDING envknob, off by default).

Attack walkthrough

From any machine on the tailnet, as a user whose SSH policy matches the target host with a non-root local user:

ssh -N -L 127.0.0.1:2375:127.0.0.1:2375 <nonroot-user>@<target-host>
DOCKER_HOST=tcp://127.0.0.1:2375 docker ps        # if the target runs dockerd -H tcp://127.0.0.1:2375

or point -L at kubelet 10250, etcd 2379, a Postgres that trusts local connections, the host’s cloud metadata IP, or an internal service that only accepts connections from that host. The connection is made by tailscaled with euid 0, so source-based restrictions (“localhost only”, “this host only”) do not apply.

Caveats — why this is still worth fixing

Stock OpenSSH also permits unrestricted forwarding destinations by default, so this is partly “parity”. The deltas that make it a real exposure in the Tailscale SSH model:

  1. Tailscale SSH positions the tailnet ACL as the access-control layer, and its SSH rules offer no forwarding-restriction knob at all — an admin cannot express “nonroot SSH yes, forwarding no/only-to-X” even if they want to.
  2. autogroup:nonroot deployments (exactly the population highlighted in TS-2026-006/009) grant broad fleets of hosts to broad sets of users, multiplying the reach of each user’s forwarding privilege.
  3. Tailscale’s own bulletins TS-2026-004/005 established that “tailscaled-as-root connecting to privileged endpoints on behalf of a less-privileged user” is treated as a vulnerability class. This is that class on the TCP side.

Suggested remediation

  1. Privilege-separate the dial (same fix as report 1): dials made as the session user’s uid make root-only and localhost-only services unreachable by construction.
  2. Add a PermitOpen analog to SSH actions/policy, and/or default-deny forward destinations in loopback + link-local/metadata ranges unless explicitly allowed.
  3. At minimum, document the exposure for autogroup:nonroot users.

Detection

tailscaled logs accepted forward channels; there is no per-destination logging today. Suggested: log destination host:port for every accepted direct-tcpip channel, and alert on loopback/metadata destinations from non-root sessions.