The Allowlist Is the Attack Surface: Probing My Tailnet After Tailworm
Six days after PHP2Shell, the mesh my whole homelab sits on had a disclosure of its own. How I audited my own fleet, the two times my own probe lied to me, and the rollout that didn't lock me out.
Six days after PHP2Shell had me asking every container what it was actually running, a second disclosure landed. This one was worse, because of where it hit. It wasn't an app, or a runtime inside an app. It was Tailscale, the mesh every node in my homelab sits on.
This is the story of auditing my own tailnet against it: what the report claims, what I built to check, the two times my own probe told me something false, and how I rolled out the fix without locking myself out.
What Tailworm claims
Tailworm is a write-up from Cyberdione Labs: 11 findings against Tailscale 1.104.2 and headscale 0.29.5, published without a patch available. The headline is the first three, all in Tailscale SSH's forwarding:
- Forwarding runs inside root
tailscaled. When you forward a unix socket over Tailscale SSH, the daemon that dials it is running as root, not as the user you logged in as. - F2: a remote forward (
ssh -R) can make the node create a new socket in a directory it allows. - F3: a race. The server checks the socket path, then dials it, as two separate steps. Swap the path for a symlink in between, and a forward that passed the check ends up connected to a root-only socket like
docker.sock.
Put together: any SSH grant to a node, even a "non-root" one, could be turned into root. Two caveats matter. The attacker needs an approved device on your tailnet and an SSH rule to start with, so this is lateral movement, not something reachable from the internet. And Tailscale hadn't confirmed the lab results at the time of writing.
Some people called the write-up AI slop. I didn't want to argue about it. I wanted to know whether its precondition was true on my nodes. It was.
The mitigation, and what it costs
Tailscale's investigation update pointed to an environment variable, TS_SSH_DISABLE_FORWARDING=1, set for tailscaled, with a patched client, an advisory and a technical post to follow. As I write this, no patched release has shipped, though commits touching these code paths have been landing in the public repo.
The variable is blunt. It doesn't just close unix-socket forwarding. It turns off all of it.

So before changing anything, I went looking for my own tunnels. This finds the obvious ones on the machine I connect from:
grep -rnE 'LocalForward|RemoteForward|DynamicForward' ~/.ssh/ 2>/dev/null
grep -rnE 'ssh .*-[LRD] ' ~/bin ~/scripts ~/.bash_aliases 2>/dev/nullI had one I actually relied on: an ssh -L to a cluster observability UI. It turned out I'd already published that UI with tailscale serve, which gives it an HTTPS address on the tailnet with no SSH involved. I deleted the old tunnel so nothing would quietly break later.
Building the audit
I didn't want to guess which nodes were exposed, so I built a toolkit, tailworm-audit, in three parts:
- A read-only collector that runs on each node, in plain POSIX
shso it works on routers too. It reports howtailscaledis running, what its environment contains, and whether there's evidence of forwarding. It redacts everytskey-*and anything secret-looking before output leaves the node. - A fleet runner that finds every peer advertising Tailscale SSH, collects from them in parallel, and can actively probe what each SSH server will forward.
- An analyzer that turns all of it into a report, with exit codes that work in a scheduled job.
I run it from WSL on my laptop. One quirk worth knowing: I don't run a Linux Tailscale client inside WSL, so the runner detects Windows' tailscale.exe and uses plain OpenSSH over the Windows client's tailnet connection.
The collector was the easy part. The probe is where I learned the most, because it was wrong twice.
Mistake 1: the probe that hid errors
The first version opened a unix-socket forward to tailscaled's own socket and tried a harmless status read through it. No answer meant blocked. To keep the output tidy, I had ssh's log level turned down.

The probe said blocked, before any fix. The config check said forwarding was on. One of them was lying, and I almost took the answer I wanted. Instead I said no at the apply prompt and rebuilt the probe to read the SSH server's own channel messages. That's the message that explains why a forward failed. A probe that hides it can't tell "refused" from "nothing there," and scores both as a pass.

Mistake 2: reading a refusal as safety
The rebuilt probe got a clear answer from the server: administratively prohibited. My first reading was good news. The server refused the forward to tailscaled.sock, so this path was already closed and maybe I didn't need to restart anything.
Then I looked at the full message on a few nodes. It wasn't a flat refusal. It said the socket path was not in an allowed directory. Tailscale SSH wasn't refusing unix-socket forwarding at all. It was allowing it into certain directories, and my probe had aimed at a path outside them. That allowlist is exactly what F3 races past.
So the probe was rebuilt again, this time to map the allowlist: try a few paths inside and outside it, try a remote bind in /tmp, and report what the server did with each.


lstat in those messages shows it: check first, dial second. F3 lives in between.docker.sock looks reassuring. It's the line just below it, /tmp: reached, that matters.Both mistakes have the same shape as the one in my PHP2Shell post: a quiet answer that looks like good news. An inventory that silently comes back short and a probe that silently comes back "blocked" fail the same way. Test against something where you already know the answer.
Rolling it out without cutting the branch I was sitting on

On systemd nodes the change itself is a small drop-in:
# /etc/systemd/system/tailscaled.service.d/disable-forwarding.conf
[Service]
Environment=TS_SSH_DISABLE_FORWARDING=1The catch is the restart. I was applying this over Tailscale SSH, so restarting tailscaled drops the very session that made the change, before it can report back. The apply step schedules the restart as a separate, detached job a few seconds later, so the session survives long enough to say it worked. One way to do that:
systemctl daemon-reload
systemd-run --on-active=15 --unit=tailscaled-restart systemctl restart tailscaledThen the part that's easy to skip: configured isn't live. The variable does nothing until tailscaled restarts, so check the running process, not the unit file:
tr '\0' '\n' < /proc/$(pidof tailscaled)/environ | grep TS_SSH_DISABLE_FORWARDINGAnd then probe again. The canary went from allowlisted to every path refused, -R included. By the last node, the toolkit did all of it in one command: write the drop-in, schedule the detached restart, wait, then collect and probe again.

After that, three batches: the agent containers and cluster workers first, then general servers, then the nodes that sit in front of other things, at a quiet hour. Each batch re-probed itself before the next one started. The last node waited a day while I confirmed the one tunnel it served had already moved to tailscale serve.
What the write-ups don't mention
Running this against a real fleet turned up things none of the coverage I read talked about.
- Every containerized
tailscaledis its own SSH server. A Proxmox host with tailnet-joined LXCs, or a sidecar container started with--ssh, needs its own mitigation. Fixing the host doesn't fix them. - Stray auth keys make the race unnecessary. Twenty-nine containers carried a Tailscale auth key in their environment, mostly copies of one key from a shared
.envfile, including containers that don’t run Tailscale at all. Whether that matters depends on the key: an expired or used one-off key is dead weight, but a reusable, unexpired one lets anyone who can read container config add a device to your tailnet. With state on a volume andTS_AUTH_ONCE=true, a container only needs the key for its first login, so it can come out of the environment after that. - Once a container has joined, it doesn't need the key anymore. Its identity lives in its state volume. Revoke the key in the admin console and take it out of the environment.
- Any-port grants don't need a bug at all. A grant that reaches every port on a Docker host reaches a Docker API too, if one is listening.
To find the keys, this lists only the names of containers whose environment contains one, never the value:
for c in $(docker ps -q); do
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$c" | grep -q 'tskey-' \
&& docker inspect -f '{{.Name}}' "$c"
doneNot every node needs the same panic
Tailworm is a privilege escalation: it turns a limited SSH login into root. On a device where every SSH login already lands as root, there's nothing to escalate to. My travel routers, a KVM and a firewall's Tailscale sidecar are like that. I left Tailscale SSH on there, kept their grants tight, and spent the time on the nodes where it mattered.

| What | Count |
|---|---|
| Nodes with forwarding off, verified by a live probe | 22 |
| Root-only devices left on (routers, a KVM, a firewall sidecar) | 5 |
| SSH servers in my netmap I can't audit (other people's devices) | 2 |
| Containers with a stray Tailscale auth key | 29 |
| Checks in tailworm-audit (node, policy, API) | about 50 |
| Unit tests | 24 |

What's left
- Key cleanup. Revoke every stray auth key and take them out of the env files. This is the biggest real risk the audit found, and it has nothing to do with the CVE.
- Re-run with the patched version as the floor. When Tailscale ships the fix, the audit takes the version as a setting, the same way the runtime check takes
FLOORS. Nodes on the patched build can get forwarding back where something genuinely needs it. - Tailnet Lock. After the keys are gone: two signing nodes, the disablement secrets stored offline, and pre-signed keys for containers.
- Automation. An Ansible role for the drop-in, and Wazuh rules that alert on forwarding activity in
tailscaled's logs, so this becomes a notification next time instead of a weekend.
The tool
tailworm-audit is finished and tested on my own fleet. I'm giving it a final secrets scan before it goes public, the same way I published php-node-runtime-check. When it's up, run it read-only first. The collector is short enough to read before you run it, and you should.
I built it with an AI assistant as a pair. Both mistakes above were caught by reading the actual output instead of trusting a verdict, mine or the tool's. I reviewed and ran every change myself, canary first.
Two disclosures in six days taught me the same lesson twice. Don't trust the name on the image, and don't trust the silence from a probe. Ask the thing that's actually running, and read what it says back.
Update: Grab the repo, read the script first, and run it before the next advisory.
Update: watching it with Wazuh
Mitigating is half the job. The audit is a snapshot. It tells you the fix is live today, not that it's still live after the next package upgrade, config change, or a revert someone else made.
So tailworm-audit now ships Wazuh rules in four layers:
- Posture, hourly. Each agent runs
tsctl.sh status, synced from the manager's shared folder. It alerts if Tailscale SSH is on with forwarding enabled, or if the fix is configured but not live yet. - Regression. File integrity monitoring on tailscaled's drop-in and environment file. If the mitigation drop-in disappears, that's a level 12 alert, because a revert you didn't make looks exactly like one you did.
- Tripwires. Two auditd rules on root tailscaled. One fires if it connects to a filesystem unix socket, the other if it unlinks or replaces
docker.sock. With forwarding off, root tailscaled has no reason to do either. - Crash loops. Tailworm's F5 is a memory-exhaustion loop in root tailscaled, so there's a rule for the OOM killer taking it out and another for three crash restarts in ten minutes.
A tripwire that should never fire is the highest-signal alert you can have. Most alerts need context before you can judge them. These don't: if root tailscaled touches docker.sock on a node where forwarding is off, something is wrong.
Two limits. The auditd rules match tailscaled by its binary path, so a containerized tailscaled (sidecars, Kubernetes proxies) isn't covered, and the audit's container checks are the backstop there. And they catch the forward or the socket swap happening, not the F3 race on its own.
Rules, agent config and sample events for wazuh-logtest are in the repo's wazuh/ folder: github.com/goodelyfe/tailworm-audit