Cybersecurity

A Certificate Authority's Private Keys Were the Numbers 4, 56 and 66

Eight eMudhra intermediate CA keys turned out to be recoverable from their own public certificates. Not stolen — computable, because the key generation produced almost no entropy. A researcher pulled all eight in under a second and signed a message with each one to prove it.

A Certificate Authority's Private Keys Were the Numbers 4, 56 and 66
Photo by Matt Artz / Unsplash

Browsers fixed this in under two hours. Your servers probably still haven't.

That gap is the whole story, and it's why I'm writing about a certificate authority incident two weeks after every security newsletter already covered it. They all covered the browser half. The browser half was finished on October 17.

The half that's actually your problem — Go binaries, Node services, Python containers, Java keystores, that appliance nobody has credentials for — is still open on a lot of estates tonight.

But first I have to tell you what the private keys were, because I've read a lot of CA incident reports and I've never read one like this.

The keys were small numbers

Eight eMudhra intermediate CA certificates. NIST P-384 keys, which should carry somewhere in the region of 384 bits of entropy.

Kudelski Security reports the effective private key values as:

4, 56, 66, 115, 155, 156, 230, 237

I want to be careful, because that's the most striking claim in this whole incident and I've only found it in one place. The official disclosure site deliberately does not publish the keys — its proof.zip holds certificates, public keys and signatures over a test message, nothing more. So treat the specific values as Kudelski's reporting, not as something I've independently confirmed.

What isn't in dispute is the consequence. The disclosure site puts it plainly: "The private keys for the intermediate certificates were incorrectly generated by the CA. This means that anyone can recover them from the certificates."

Google's framing, via The Hacker News, is that the keys were "generated with too little randomness."

Nobody broke elliptic curve cryptography. Nobody stole anything. A certificate authority published certificates whose private keys you can work out by trying small integers.

Scott Helme recovered all eight from the public certificates in under a second. Mozilla recovered them within two hours of the report, independently, without knowing the reporter's method. Mozilla has also said multiple people recovered them independently.

How you get here

eMudhra's preliminary root cause: a key ceremony on 30 April 2025, one of a batch of 24 intermediates. Per the Mozilla bug, "a flaw in the script used on that day's key-generation ceremony produced a second, weaker set of keys." A key wrap/unwrap step generated a second, weak pair alongside the intended strong one. The weak one got used for the CSR.

So the strong keys were generated. They exist. They just aren't the ones that went into the certificates that went into every browser on earth.

Kudelski's summary of what went wrong is three lines, and every one of them is a shape you'll recognise: a flaw in the ceremony script, an approved test deviation, human errors at multiple points.

If you've ever run a process where step three's output feeds step four and somebody wired up the wrong variable — that's this. Except the artifact was a trusted CA key, and nobody noticed for eighteen months.

The disclosure is a nice piece of work

The researcher is credited only as Wayne. They reported it on the evening of October 17, and proved possession by signing a message with each of the eight compromised keys.

The message read: "We have a problem here - Wayne"

That's the correct way to report this. No ambiguity, no "trust me," nobody needing to reproduce an attack before acting. Eight signatures that could only exist if the claim was true. A root program can act on that in minutes.

They did.

The response was fast

Time (UTC) What happened
Oct 17, 18:05 Chrome Root Program receives the report
Oct 17, 19:45 Chrome CRLSet block live for the six TLS intermediates
Oct 17, 20:06 Mozilla receives the report
Oct 17, 22:59 Mozilla's OneCRL block complete for standard Firefox settings
Oct 19 Apple blocks. Downstream trust stores notified. Go's security team learns of it
Oct 20 Microsoft blocks. Go ships urgent releases. Mozilla pulls the root from NSS. Ubuntu, Debian and Composer ship fixes
Oct 21 Node.js ships fixes. CERT-In advisory. Red Hat publishes RHSB-2026-012
Oct 22 eMudhra's board decides to exit the global SSL/TLS business
Oct 27 Chrome's removal of emSign ECC Root CA - G3 reaches stable

One hour and forty minutes from report to Chrome block. Mozilla, under three hours. I have worked incidents where it took longer than that to get the right people onto a call.

Credit where it's due — the root programs are the part of the web PKI that works.

And then eMudhra left

On October 22 the board decided to exit the global SSL/TLS certificate business entirely. The business was about 1% of revenue; the stated reasons were data sovereignty and compliance costs, plus the fact that large providers now give certificates away free.

No exit date yet. Affected customers get replacement certificates from partners at no cost, and eMudhra says it'll run revocation and status services until every SSL/TLS certificate it issued has expired or been replaced — which is the right commitment to make and the one that actually costs money. Their other lines continue: digital signature certificates, eSign, the CertiNext platform.

Google's instruction to affected operators, meanwhile, was not gentle. Site owners "must immediately acquire and install a new TLS certificate from another PKI hierarchy included in the Chrome Root Store."

Worth knowing if you're tracking this: Mozilla has a separate bug open to remove emSign Root CA - C1 and emSign ECC Root CA - C3 from NSS. As of October 20 Mozilla was not removing emSign Root CA - G1, and the G1 RSA roots keep serving customers through the transition. So "which eMudhra roots are trusted" is still moving. Don't cache the answer.

Here's the part that's your problem

Browsers check revocation. They update CRLSets and OneCRL automatically, out of band, without you lifting a finger.

Almost nothing else on your network does.

Your Go binary has the root bundle compiled into it from whenever you last built it. Your Node service ships NSS roots with the runtime. Your Python container has whatever certifi got pinned in a lockfile eleven months ago. Your Java app has a keystore somebody set up in 2021. None of these check revocation. None of them have the faintest idea that eight intermediates became worthless on a Friday evening in October.

They'll keep trusting those chains until you change their root bundles.

Rescana's breakdown of what's exposed:

Client Fixed in
Go 1.26.10, or 1.27.3 — 1.25 and older get no fix
Node.js 22.23.4, 24.22.1, or 26.12.1
Python certifi 2026.10.19
Rust webpki-roots 1.0.10
Composer ca-bundle 1.6.0 / 1.4.16
Debian / Ubuntu DSA-6572-1, USN-8944-1 and -2
NSS 3.131.1

Plus the long tail nobody wants to think about: Java keystores, mTLS trust stores, origin servers, proxies, load balancers, VPN appliances, IoT and embedded devices.

Go is the worst of these, and not because the fix is hard. Go statically links its trust store into the binary. Patching Go fixes nothing — you have to rebuild and redeploy every binary. On Go 1.25 or older there's no fix at all; that's a version upgrade, not a patch.

If you've wondered why a Go release carried a CVE for this, that's CVE-2026-111943, and you may have seen it attached to something else entirely. Gitea's 28.3.0 release carries it, because Gitea rebuilt with Go 1.27.3. Every Go application inherits it the same way. Your trust store is a transitive dependency and it updates on somebody else's schedule.

Java is worse, and the obvious fix doesn't work

I flagged Java as a gap when I first looked at this. Guillaume Laforge wrote it up properly, and there's a trap in it I'd have walked straight into.

OpenJDK, Temurin and GraalVM use a static bundled trust store, don't enforce Certificate Transparency by default, and have revocation checking off by default. So a JVM has none of the three mechanisms that saved browsers.

Now the trap. Those eight intermediates exist as sixteen certificates: eight signed by eMudhra's emSign ECC Root CA - G3, and eight cross-signed by Sectigo's AAA Certificate Services root.

Java's cacerts trusts the AAA root.

So if you do the obvious thing — find the emSign root, delete it, feel good — the eight cross-signed chains are still trusted. You've fixed half of it and you'd never know, because the check most people would run only looks for emSign:

keytool -list -cacerts -storepass changeit -v | grep -i "Owner:.*emSign"

That command is not wrong. It's just insufficient, and insufficient in a way that looks like success.

You could delete the AAA root too, and the escalated mitigation does exactly that:

keytool -delete -cacerts -storepass changeit -alias "emsignrootcag1 [jdk]"
keytool -delete -cacerts -storepass changeit -alias "emsignrootcag2 [jdk]"
keytool -delete -cacerts -storepass changeit -alias "emsigneccrootcag3 [jdk]"
keytool -delete -cacerts -storepass changeit -alias "comodoaaaca [jdk]"

But that last line distrusts the entire Sectigo/Comodo AAA hierarchy, which will break legitimate legacy endpoints you probably depend on. You're trading one outage for another.

The better fix is blocked.certs — block the specific compromised certificates by SHA-256 fingerprint rather than nuking roots:

$JAVA_HOME/lib/security/blocked.certs

Laforge's list runs to 48 fingerprints: the sixteen certificates plus alternate DER and ECDSA encodings. Go get the hashes from his post rather than from me; a truncated fingerprint list is worse than none.

Three more things that'll catch you:

Every JDK needs its own patch. blocked.certs lives in each installation's lib/security. If you've got four JDKs under SDKMAN, that's four edits.

GraalVM native images bake the trust config in at build time. Patch the GraalVM installation, then rebuild your executables. The binary you shipped last month is not going to fix itself.

Containers freeze the trust store at image build. Either add a RUN step that appends the hashes, or wait for Temurin/Corretto/Zulu to ship updated JDKs and rebuild.

And a specific Debian/Ubuntu gotcha: Java's store there lives at /etc/ssl/certs/java/cacerts, and updating OS packages isn't enough because of a ca-certificates-java bug. You have to recreate it:

sudo rm -f /etc/ssl/certs/java/cacerts && sudo update-ca-certificates --fresh

Two more traps

Restart your services. Resumed TLS sessions aren't re-verified. You can patch Node, leave the process running, and keep happily trusting a session established against a dead chain.

Things will break, and that's correct. Mozilla, Canonical and Red Hat all warned about it. Removing the root cuts off legitimate certificates that chain to it, so after you patch you may start seeing handshake failures from partners still on emSign chains.

The temptation then is to disable verification "just for that one integration, just until they migrate." Don't. You'd be turning off certificate checking in response to a certificate authority compromise, which is precisely the wrong direction. Make them migrate. Google already told them to.

How to check your own estate

There's no single command. You're hunting a root across every trust store you own, and "every trust store you own" is a bigger set than you think.

System store first:

# Debian/Ubuntu
grep -rl "emSign" /etc/ssl/certs/ 2>/dev/null
awk -v cmd='openssl x509 -noout -subject' '/BEGIN/{close(cmd)};{print | cmd}' \
  /etc/ssl/certs/ca-certificates.crt 2>/dev/null | grep -i emsign

# RPM distros
trust list 2>/dev/null | grep -i -A2 emsign

Then the runtimes, which is where it actually hides:

python3 -c "import certifi; p=certifi.where(); print(p); print('emSign found' if 'emSign' in open(p).read() else 'clean')"
node -e "console.log(process.version)"
go version

I ran that on a build box while writing this, expecting it to come back clean:

/root/.local/lib/python3.13/site-packages/certifi/cacert.pem
emSign found
go version go1.24.7 linux/amd64

emSign found. And Go 1.24.7, which is below 1.26.10 and therefore in the "no fix available, upgrade the toolchain" bucket.

That's a machine that gets rebuilt constantly, running current-ish everything, three weeks after the incident. It is not an unusual box. Run the certifi line against every container image you ship, because I'd bet money somebody's base image is pinned.

What this doesn't cover

No exploitation detail, because there isn't any to give. Chrome reported that no TLS certificate chaining to the six TLS intermediates had been logged to Certificate Transparency before the block, and eMudhra says only one of the eight was ever deployed and none issued end-entity certificates.

Read that narrowly, though. CT only covers logged certificates. The disclosure site is straight about the limit: "We do not know if attackers exploited the flawed key generation to target these clients." Those certificates existed from April 2025. Anybody who found what Wayne found could have issued certificates for any domain and used them against clients that don't enforce CT — which is, again, most non-browser clients.

Nobody can prove that didn't happen. The evidence to settle it doesn't exist.

I'm still not covering code signing properly. Two of the eight were code-signing CAs (EM OV CS CA - G3B and G3C), and Chrome didn't block those because its TLS verifier can't build a path through them. That's a different pipeline with different stores and I haven't done the work.

A counting note: you'll see 8 and 16. Eight intermediates, sixteen counting the Sectigo cross-signs. Both are right about different things, and as the Java section shows, the difference between them is load-bearing.

What I'd actually do

Rebuild your Go binaries. All of them. No shortcut on that one.

Patch Java with blocked.certs, not by deleting roots, and remember the cross-signs. If you only remove the emSign root you've done half the job.

Update bundled roots — certifi, webpki-roots, NSS, ca-bundle — and check lockfiles including transitive pins. Apply the OS ca-certificates update and then verify your containers and golden images actually picked it up, because an image baked in August did not.

Upgrade Node and restart the services.

Then go find the stores you forgot. The appliance. The load balancer. The mTLS bundle in the config repo. The embedded thing in a cupboard.

The lesson isn't really about eMudhra, who are leaving the business and will be fine. It's that we run two completely different patch velocities for the same trust decision. Browsers measured their response in minutes. Everything else measures it in whenever-somebody-rebuilds — and for GraalVM native images and container base layers, that might be never, because nothing will ever prompt you.

If you've read my host hardening piece, this is the same argument in a different coat. The thing that gets you is rarely the layer you're looking at. Here it's a trust store you've never opened, compiled into a binary you haven't rebuilt since spring, validating certificates against a CA you'd never heard of until tonight.

Go rebuild your binaries.

$ tail -f /forums

Discussion

Comments live on the community forums — sign in there to reply.