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
tailscalev1.102.4 (commit3caf7d9e7dcaba589cfc58beda596929733e4fea) andmainas of 2026-10-24; thedirect-tcpiphandler and callback shape have been stable for a long time — I have not established the earliest affected version.github.com/tailscale/glidersshv0.3.4-0.20260716005906 (tcpip.go,DirectTCPIPHandler).- headscale v0.29.5 compiles accept actions with
AllowLocalPortForwarding: true(hscontrol/policy/v2/filter.go), so headscale tailnets get this by default.
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:
- 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.
autogroup:nonrootdeployments (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.- 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
- 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.
- Add a
PermitOpenanalog to SSH actions/policy, and/or default-deny forward destinations in loopback + link-local/metadata ranges unless explicitly allowed. - At minimum, document the exposure for
autogroup:nonrootusers.
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.