Curl Said Fine, Node Said Otherwise: Checking Two Servers for the eMudhra Certificate Compromise
This morning I got an email from Cloudflare asking me to update my tunnels to cloudflared 2026.10.2. The reason was a post on the Cloudflare blog about eight compromised eMudhra intermediate certificates. I run two small sites behind Cloudflare Tunnels, on two separate Debian 13 servers, so I did what the email said. Then I checked whether that was actually enough. It wasn’t, and the gap is the interesting part.
What happened
According to Cloudflare, it learned at about 17:00 UTC on 19 October 2026 that the private keys for eight eMudhra intermediate certificates were compromised. The keys are weak, so an attacker can create a forged certificate for any domain and use it to impersonate servers. These intermediates were widely trusted by browsers and operating systems, and also inside Cloudflare.
This is a trust problem, not a software bug. TLS encryption itself is not broken. What breaks is authentication: a client that still trusts the compromised intermediates will accept a forged certificate as genuine. Cloudflare says its security review found no active exploitation on its services.
Browser vendors could fix this with a quick revocation push. Everything else needs its own update: operating system trust stores, language runtimes, and clients such as cloudflared. Cloudflare’s fix for tunnels is cloudflared 2026.10.2.
Step 1: updating the tunnels
One server installed cloudflared from the Cloudflare apt repository. That update was one command, followed by a restart:
sudo apt update && sudo apt install --only-upgrade cloudflared -y
sudo systemctl restart cloudflared
cloudflared --version
It went from 2026.9.3 to 2026.10.4, which is newer than the version Cloudflare asked for.
The second server was different. apt-cache policy cloudflared showed the installed version and the same version as the candidate, which means apt had no repository to check. I had installed it from a downloaded .deb file at some point, so it would never have seen an update on its own. My unit file also passes --no-autoupdate, so cloudflared will not update itself either. On that box, a manual download was the only path.
I installed the 2026.10.2 .deb from the GitHub release page, and the dashboard showed that tunnel in amber next to the other one in green. A request to the site returned a 502 straight after the restart. That was timing: the tunnel had registered one of its two connections and the second was still starting. It cleared a few seconds later.
To stop this happening again, I added the Cloudflare repository to that server so apt manages it from now on:
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main' | sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt update
sudo apt install --only-upgrade cloudflared -y
sudo systemctl restart cloudflared
Both packages left my custom systemd unit alone. I checked with systemctl cat cloudflared | grep ExecStart and it still showed the same --no-autoupdate --config ... tunnel run line.
If you want the background on how I set tunnels up in the first place, Part 5 of the From Dropbox to Daily Driver series covers a Cloudflare Tunnel with a Zero Trust Access policy.
Step 2: what do the other services trust?
Updating cloudflared deals with one program. These servers run other software that makes outbound HTTPS calls, and each program decides for itself which certificates to trust. Cloudflare says as much in its post: “A successful browser test does not prove that a background service is protected. Neither does a curl test if the application uses a different verifier.”
Mozilla’s Dennis Jackson runs a test site with four hostnames that serve certificates chaining to the compromised intermediates. If a client connects successfully, it still trusts them. A certificate error is the result you want. Two of the hostnames are variants without Certificate Transparency. His page notes that many non-browser clients, curl included, do not enforce it.
His page only gives curl commands, so I added a Node version. Both servers run a small Node application, and Node is the verifier that matters on these boxes. I ran this on each server.
The curl test:
for h in dv-g3b-emudhra dv-g3b-aaa-emudhra dv-g3b-noct-emudhra dv-g3b-aaa-noct-emudhra; do echo -n "$h: "; curl -sS -o /dev/null -w '%{http_code}\n' --max-time 10 https://$h.we-have-a-problem.com/ 2>&1 | head -n 1; done
The Node test:
node -e "for (const h of ['dv-g3b-emudhra','dv-g3b-aaa-emudhra','dv-g3b-noct-emudhra','dv-g3b-aaa-noct-emudhra']) fetch('https://'+h+'.we-have-a-problem.com/').then(r=>console.log(h,'TRUSTED',r.status)).catch(e=>console.log(h,'rejected',e.cause?.code||e.message))"
The result
| Verifier | Server 1 | Server 2 |
|---|---|---|
| curl, system trust store (ca-certificates 20250419+deb13u1) | all four rejected | all four rejected |
| Node, before the update (v24.21.0 and v22.23.2) | 2 of 4 trusted | 2 of 4 trusted |
| Node, after the update (v24.22.1 and v22.23.4) | all four rejected | all four rejected |
curl said both servers were fine, and for curl that was true. The system certificate package was already patched. Node said otherwise on both. It trusted dv-g3b-emudhra and dv-g3b-noct-emudhra and connected with a 200. The two aaa hostnames were rejected with UNABLE_TO_GET_ISSUER_CERT_LOCALLY. I don’t know why those two behave differently from the other two, and Jackson’s page doesn’t explain it, so I am reporting what I saw.
As far as I know, Node ships its own set of root certificates instead of reading the system’s by default. That fits the different answers, though I didn’t dig into the mechanism. A check based on curl or the browser would have passed these servers while the application doing the real work stayed exposed.
Fixing Node
Jackson’s page lists the patched Node versions as 26.12.1, 24.22.1 and 22.23.4. Both servers get Node from the NodeSource apt repository, so the fix was another targeted upgrade:
sudo apt update && apt-cache policy nodejs
sudo apt install --only-upgrade nodejs -y
node --version
After the update I reran the Node test and all four hostnames came back rejected CERT_REVOKED. I read that as the patched build explicitly revoking the compromised intermediates, instead of failing to find an issuer, which is a firmer result than the earlier error.
There is one step that is easy to miss. A new node -e command starts a fresh process and uses the new binary, so it reports the patched behaviour straight away. A long-running service keeps the old binary loaded in memory until it is restarted. My MCP server is exactly that kind of service on both boxes:
sudo systemctl restart mcp-server
systemctl status mcp-server --no-pager | head -n 4
Until that restart, the test said “patched” while the service doing the work was still on the old Node. The restart drops that service for a few seconds, and I confirmed afterwards that it came back up with its saved OAuth tokens intact.
How worried should you be
Taking advantage of this needs someone in a position to intercept the outbound connection, such as on the network path between your server and the API it calls. For a homelab that is a low-probability event. That is not a reason to skip the check, because the fix took a few commands per server, and the thing I learned (that my checks were looking at a different verifier from the one that matters) applies to any estate.
It has the same shape as the problem in my post on the Ted backdoor hiding in a recompiled HAProxy: the thing you check and the thing doing the work are not always the same, and a clean result from one says little about the other.
What I would check on your own servers
- cloudflared: run
cloudflared --version, and check how it was installed. Ifapt-cache policy cloudflaredshows no repository candidate, it was a manual install and will not update itself. - System certificates: on Debian 13,
ca-certificates20250419+deb13u1 or later is patched. - Runtimes with their own trust stores: Node, Go, Python’s certifi package and the JDK all bring their own. The patched versions listed on Jackson’s page when I checked were Node 26.12.1, 24.22.1 or 22.23.4, Go 1.27.3 or 1.26.10, and certifi 2026.10.19. OpenJDK was still listed as pending.
- Restarts: restart every long-running service that uses a patched runtime, and test the service, not only a fresh shell.
- New certificate errors after patching: do not switch verification off to make them go away. Cloudflare says plainly that doing so removes the protection the update is meant to restore.
I wrote and published this post through the MCP server that I restarted after the Node update, which makes it a small end-to-end test of the fix.