Back to the Lab

The Lab · Development

eMudhra CA Break: Patch List for 6 Runtimes

Eight eMudhra keys were numbers below 256. The fixed versions for Node, Bun, Python, Java and PHP, a probe that proves each, and a Vercel guard.

RAXXO Studios 9 min read
TLDR This entry in one minute

Each line jumps to its section

  • Eight eMudhra intermediate CA keys were numbers below 256, so anyone could forge a certificate for any domain
  • Safe floors: Node 24.22.1, Bun 1.4.4, pip 26.2.2 in every venv, certifi 2026.10.19, uv 0.13.4
  • Java needs Oracle's update or blocked.certs, and Vercel Functions still ran Node 24.21.0 three days after the fix
  • A curl probe proves each runtime rejects the chain, and a 20-line guard covers Vercel until it patches

One certificate authority shipped private keys you could find by counting, and every TLS client I run needed a fix inside the same 72 hours. Node, Bun, Python, Java, PHP, and the serverless runtime a lot of web apps deploy to. This is the eMudhra CA break. Below is the patch list I worked through, runtime by runtime, with the exact versions that fix it and a way to prove each fix worked.

What the eMudhra Break Actually Was

eMudhra runs a certificate authority. Its root, "emSign ECC Root CA - G3", sits in the trust stores of browsers and runtimes. Under that root were eight intermediate certificates, generated in a key ceremony in April 2025.

Their private keys were tiny. Every one of them was a number below 256. One was 4. Not four bytes, the number four.

An elliptic curve key that small isn't a secret. A script finds it in milliseconds. Whoever holds an intermediate's key can issue a certificate for any hostname, and any client that trusts the chain accepts it. That includes the payment API your backend calls every minute.

The public timeline:

  • October 18: Chrome's root program got the report at 18:05 UTC and blocked the intermediates about two hours later. Mozilla pushed a OneCRL entry the same evening.
  • October 19: Apple followed, and eMudhra revoked all eight.
  • October 20: Microsoft followed. Python's packaging team posted its advisory.
  • October 21: Node.js and Oracle shipped fixes. OpenJDK published an advisory.
  • October 27: Chrome drops the G3 root completely.

Browsers were safe within a day. Everything else is the problem.

Revocation doesn't rescue you outside the browser. Node doesn't check certificate revocation by default, and neither do Bun or Python's ssl module. Each carries its own list of trusted roots. While G3 is on that list, a forged chain under it validates.

There's no evidence anyone used the keys, and eMudhra says most of the intermediates never went into production. Still, "no evidence" isn't a patch. The attack needs only a network position between your code and the server it calls. Hotel Wi-Fi is enough. So is one compromised router.

The Patch Floor for Node, Bun and Python

These three ship their own certificate bundles, so an operating system update does nothing for them.

Node.js released 22.23.4, 24.22.1 and 26.12.1 on October 21. The release updates the bundled root store and carries other security fixes too, including a Brotli decoder bug. Anything older on those lines still trusts the chain. Node 20 is end of life and got nothing, so this is a good week to move off it. Check your CI files for old pins too.

CI deserves its own look. A setup step that asks for node-version: 24 can resolve to a cached older 24.x on the runner. Adding check-latest: true to actions/setup-node forces the newest release. Then print node --version in the job and run the probe below, so the log proves it.

Bun keeps its own root store and ignores SSL_CERT_FILE. I tested it instead of guessing. Bun 1.3.13 and 1.4.3 both connected to the eMudhra test host. Bun 1.4.4 refuses it with CERT_REVOKED. Check your CI pins, older 1.3.x versions are common there.

Python is the sneaky one. The interpreter's ssl module reads whatever CA file it was built against, but the packaging tools carry their own. pip always loads its vendored copy of certifi alongside the system store, and no flag switches that off. A fully patched system can still run an exposed pip.

Fixed versions:

  • pip 26.2.2
  • certifi 2026.10.19
  • uv 0.13.4
  • conda 26.9.4

Upgrade pip inside every virtual environment, not just the global one. Each venv keeps its own copy of the old bundle until you do. Homebrew's Python is the exception, because Homebrew points pip's certifi at its own patched bundle.

One more Python trap. python -m venv seeds every new environment from the interpreter's bundled ensurepip wheel. Until Python itself updates, every new venv starts life with the old pip. The Python team documented a stopgap (put the new pip wheel into ensurepip/_bundled and change one version string). If that feels too invasive, make pip install -U pip the first command in every new venv.

Containers have the same issue one layer down. The Python team noted that as of October 21 the official slim Python image still shipped pip 26.2.1. Rebuild images with the cache disabled, then check the pip version inside the running container, not in your Dockerfile.

Java, PHP and the Runtime You Don't Control

Java got CVE-2026-91403, rated 9.1. Oracle's alert lists Java SE 8u511, 11.0.33, 17.0.21, 21.0.13, 25.0.5 and 27.0.1 as affected, plus GraalVM. The fix distrusts the intermediates inside JSSE. If you can't update today, Oracle documents replacing the local blocked.certs file (called blacklisted.certs on Java 8). OpenJDK packages from Linux distributions usually read the system store, so updating ca-certificates covers those.

Before reading any of that, I checked whether I run Java at all. No JDK installed, no Gradle or Maven file in any repo, no CI job calling it. Two minutes, zero work. Do that check first for every advisory, it saves a lot of reading.

PHP released 8.5.12, 8.4.27, 8.3.36 and 8.2.35 the same week. PHP's OpenSSL normally reads the system store, so your OS and ca-certificates updates matter more than the PHP version for this particular issue.

Then the runtime you don't control. Vercel Functions is the example I tested. On October 21, 22 and 23 I deployed a throwaway function that reports process.version and fetches the eMudhra test host. All three days: Node v24.21.0, CONNECTED 200. No date for an update.

You can patch that from inside the app. Node 24 exposes tls.getCACertificates() and tls.setDefaultCACertificates(). This module removes the roots at cold start:


import * as tls from 'node:tls';
import { X509Certificate } from 'node:crypto';

const DISTRUSTED = ['emSign ECC Root CA - G3', 'AAA Certificate Services'];

export function dropEmudhraRoots(): number {
  const all = tls.getCACertificates('default');
  const keep = all.filter((pem) => {
    const cn = new X509Certificate(pem).subject
      .split('\n')
      .find((line) => line.startsWith('CN='));
    return !DISTRUSTED.some((name) => cn === `CN=${name}`);
  });
  if (keep.length !== all.length) tls.setDefaultCACertificates(keep);
  return all.length - keep.length;
}

In Next.js I call it from register() in instrumentation.ts and log the count. On the old runtime it drops one root, and the test host goes from CONNECTED 200 to UNABLE_TO_GET_ISSUER_CERT_LOCALLY. Stripe, Shopify, Anthropic and Vercel Blob still connect. On a patched Node it drops zero and costs nothing. The second name is a Sectigo root that cross-signed part of the chain; Node's bundled store doesn't include it, so that line is just a precaution. Delete the module once your host runs the fixed Node.

Prove It With a Probe, Not a Version Number

Version numbers lie by omission. A patched Node can still read an unpatched store through a flag. A patched OS doesn't patch pip. So I stopped checking versions and started checking behavior.

The disclosure site runs test hosts signed through the broken chain. A safe client refuses them:


curl https://dv-g3b-emudhra.we-have-a-problem.com   # safe = error (60)

node -e "fetch('https://dv-g3b-emudhra.we-have-a-problem.com').then(() => console.log('EXPOSED'), (e) => console.log('rejected:', e.cause?.code ?? e.message))"

bun -e "fetch('https://dv-g3b-emudhra.we-have-a-problem.com').then(() => console.log('EXPOSED'), (e) => console.log('rejected:', e.code ?? e.message))"

python3 -c "import urllib.request as u; u.urlopen('https://dv-g3b-emudhra.we-have-a-problem.com'); print('EXPOSED')"

One rule matters more than the commands: a probe only passes on an explicit certificate rejection. A DNS failure or a timeout tells you nothing. Count "didn't connect" as safe and an offline laptop reports itself fully patched.

I wrapped all of this in one shell script that runs every evening and after every package manager upgrade. It probes each installed runtime, reads the current security floors from nodejs.org and php.net instead of hardcoding them, greps every certifi bundle on the disk for the G3 root, and sends a desktop notification when something drops below the floor. That last part matters: an interpreter can pass the probe while pip on the same machine still loads the old bundle, so the script greps the bundles directly instead of trusting the interpreter result.

Honestly, none of the individual fixes was hard. What I'd have lost without the script is the overview: which runtime I had already fixed, and which one I only thought I had.

My wider pre-launch routine is in The Security Review Every RAXXO Tool Passes Before Launch. If Bun's separate trust store surprised you, Bun vs Node for Solo Developers Six Months In covers where the two runtimes still differ.

Bottom Line

The eMudhra break is a trust store problem, and every runtime keeps its own trust store. Node goes to 24.22.1 (or 22.23.4, or 26.12.1). Bun to 1.4.4. pip to 26.2.2 inside every venv, certifi to 2026.10.19, uv to 0.13.4. Java gets Oracle's update or the blocked.certs file, PHP rides on your system ca-certificates. Then probe each one against the test host and accept only a certificate rejection as a pass. If your host runs an older Node, a 20-line guard at cold start closes the gap until it updates.

The habit I'm keeping is a check that runs by itself and tells me when something slips. My Claude Blueprint setup works the same way through Claude Code hooks: checks that fire on their own instead of waiting for me to remember them. Which runtime in your stack carries its own trust store that you haven't probed yet?

This article contains affiliate links. If you sign up through them, I may earn a small commission at no extra cost to you. (Ad)

Get the next entry by mail
One mail when a new entry lands. No spam. Unsubscribe anytime.
RAXXO Studios

Written by

RAXXO Studios

One designer in Berlin, close to twenty years in. I build tools with AI, use them daily, and write down what happened.

Share this entry

X LinkedIn
All entries