The eMudhra CA compromise: is your JVM still trusting it?
TL;DR: do this now
- Eight eMudhra intermediate CA keys were trivially weak. Anyone can forge TLS certificates that a default JVM trusts (CVE-2026-91403, CVSS 9.1).
- You're affected if a JVM makes outbound HTTPS and its trust store still has the emSign or AAA Certificate Services roots without the vendor blocklist.
- Upgrade the JDK or rebuild on a patched base image, and bump pinned digests. On Debian, also rebuild the Java keystore with update-ca-certificates --fresh.
- Can't upgrade today? Install the vendor's blocked.certs, remove the four roots, or turn on revocation checking.
- Restart every JVM, then verify inside the running image, not on your laptop.
What happened
On 17 October 2026, a researcher found that eight intermediate CA certificates issued by eMudhra had private keys that could be recovered from public data. Six of the eight issue TLS certificates; two sign code.
Their chains end at one of two roots:
- emSign ECC Root CA – G3
- AAA Certificate Services (Sectigo/Comodo), via a cross-sign
eMudhra says only one of the eight was used in production, and none issued certificates for websites. No exploitation had been reported as of 21 October
How weak were the keys?
The keys are ECDSA on curve P-384. A P-384 private key should carry about 384 bits of randomness. These reportedly carried about 8. The eight private keys were reported as 4, 56, 66, 115, 155, 156, 230 and 237, each a single byte.
With 256 possible keys, recovery is a loop. Try each candidate and compare it with the public key in the certificate:
# Educational: recover a P-384 key with a tiny keyspace from its public key
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509 import load_pem_x509_certificate
cert = load_pem_x509_certificate(open("intermediate.pem", "rb").read())
target = cert.public_key().public_numbers()
for d in range(1, 256):
pub = ec.derive_private_key(d, ec.SECP384R1()).public_key().public_numbers()
if (pub.x, pub.y) == (target.x, target.y):
print("private key found:", d)
break
The reported cause is a bug in the key-generation script. It created a second, weak key set on the HSM, and those keys ended up in the certificate requests. eMudhra says it hasn't yet confirmed the root cause.
Why an intermediate is worse than a leaked site key
| Leaked site (leaf) key | Compromised intermediate key | |
|---|---|---|
| What an attacker can forge | One domain | Any domain, plus signed code for the two code-signing CAs |
| Who trusts the forgery | Clients of that one site | Every client whose trust store holds the root |
| How it's fixed | Revoke one certificate | Revocation, blocklists or root removal on every client |
The exposure window was about 17 months, from April 2025 to October 2026. Browsers were protected by Certificate Transparency: no certificate from the affected intermediates was ever logged, and Chrome and Safari reject unlogged certificates. Java doesn't check CT.
Timeline
| Date | Event |
|---|---|
| Apr 2025 | Intermediates issued |
| 17 Oct 2026 | Reported; Chrome and Firefox block them |
| 18–19 Oct | eMudhra revokes them |
| 19–20 Oct | Apple and Microsoft block them |
| 20 Oct | Debian ca-certificates fix; Mozilla NSS removes the G3 root |
| 21 Oct | Oracle, OpenJDK and Azul ship out-of-band JDK builds |
| 24 Oct | OpenJDK updates its guidance (see below) |
| 27 Oct | Chrome's root removal takes effect |
Each ecosystem got its own CVE: CVE-2026-91403 (Java), CVE-2026-114406 (RHEL ca-certificates), CVE-2026-111943 (Go), CVE-2026-113812 (Perl Mozilla::CA).
Why your JVM is exposed, and when production must act
Java is the weak spot for four reasons:
- Its own trust store. Each JDK ships its own
cacerts. OS and browser updates don't touch it, unless your JDK is a distro package linked to the OS store. - No Certificate Transparency. The defence that protected browsers doesn't exist in a JVM.
- Revocation checking is off by default. eMudhra revoked the intermediates, but a default JVM never asks.
- The trust store is read once, at startup. Patched files do nothing until the process restarts.
What an attack looks like
- An attacker with network position intercepts an outbound HTTPS call from your service, for example through DNS spoofing, a rogue proxy or a BGP hijack.
- They present a certificate for
api.partner.com, signed with a recovered intermediate key. - The JVM finds a trusted root and completes the handshake.
- The attacker now reads and alters the traffic: API keys, OAuth tokens, payment payloads, personal data.
The two code-signing intermediates mean forged signed JARs can pass verification too.
Does production need to act?
| Your situation | Priority |
|---|---|
| JVM calls third parties over HTTPS (payment gateways, banks, SaaS, identity providers) | Patch now |
| JVM verifies signed JARs, plugins or artifacts at runtime | Patch now |
| JVM calls cloud provider APIs over the public internet | High |
| JVM calls only internal services with a private CA and a custom trust store | Medium: check the custom store doesn't contain these roots |
| Service only accepts inbound HTTPS, terminated at a load balancer | Lower, but most services make some outbound call |
| Your own site's certificate chains to an emSign root | Replace the certificate |
You are affected if all three hold:
- The trust store contains one of the bad roots.
blocked.certsdoesn't block the compromised certificates.- Revocation checking is off.
Fix any one and you're protected.
Don't overcorrect
Deleting the roots also breaks legitimate sites whose chains end there, as CERT-In has warned. Indian government and enterprise endpoints using emSign certificates are the likeliest to break. Test the endpoints your services actually call, and prefer the vendor blocklist where it exists.
What Oracle, OpenJDK and the vendors say
| Source | What it says |
|---|---|
| OpenJDK advisory, 21 Oct | CVE-2026-91403 in security-libs/javax.net.ssl, CVSS 9.1. Affects 27.0.1, 25.0.5, 21.0.13, 17.0.21, 11.0.32.1, 8u504 and earlier |
| openjdk.org/emudhra | Prefer a vendor update. Workarounds: remove four roots, or turn on revocation checking. Checker: CheckCerts.java |
| Oracle Security Alert | Out-of-band Oracle JDK builds, 21 Oct |
| Azul workaround | Replacement blocked.certs with a published SHA-256 |
| Red Hat RHSB-2026-012 | Rated Critical; fix via ca-certificates errata; rebuild containers |
| Debian DLA-4845-1 / DSA-6572-1 | Roots removed in bookworm 20250419~deb12u2 and trixie 20250419+deb13u1 |
Updated 24 Oct. OpenJDK's guidance now says to look for vendor builds whose release notes cite JDK-8395690 / CVE-2026-114820, and it lists no fixed builds yet. Builds from 21 Oct may be a first fix rather than the final one. Verify your running image, not the version number.
Vendor builds reported as of 23 October (roundup):
| Vendor | 8 | 11 | 17 | 21 | 25 |
|---|---|---|---|---|---|
| Oracle | 8u511 b32 | 11.0.33.0.2 | 17.0.21.0.2 | 21.0.13.0.2 | 25.0.5.0.2 |
| Azul Zulu | 8u512 | 11.0.33 | 17.0.21 | 21.0.13 | 25.0.5 |
| Amazon Corretto | 8.512.08.1 | 11.0.33.0.9 | 17.0.21.0.10 | 21.0.13.0.10 | 25.0.5.0.9 |
Corretto's changelog doesn't mention eMudhra. Temurin, Microsoft and Red Hat builds were unconfirmed at that date.
Recommended fixes, in priority order
1. Upgrade the runtime
- Standalone JDK (Oracle, Azul, Corretto, Temurin, a tarball in
/opt): install the vendor's fixed build. - Distro JDK (Debian/Ubuntu
openjdk-*, RHELjava-*-openjdk): updateca-certificates. On Debian, also rebuild the Java store (next section). - Containers: rebuild on a patched base image, bump the pinned digest, redeploy.
2. Can't upgrade today? Pick one interim fix
| Option | What it does | Watch out for |
|---|---|---|
A. Vendor blocked.certs | Replace lib/security/blocked.certs (blacklisted.certs on JDK 8) with the vendor's file | Check its SHA-256. Never edit it by hand |
| B. Remove four roots | keytool -delete emSign Root CA G1, G2, ECC G3 and AAA Certificate Services | Breaks legitimate chains; aliases differ by vendor |
| C. Trimmed trust store | Copy cacerts, remove the roots in the copy, run with -Djavax.net.ssl.trustStore | Every JVM needs the flag |
| D. Revocation checking | -Dcom.sun.net.ssl.checkRevocation=true -Dcom.sun.security.enableCRLDP=true | Adds network calls and latency to each handshake |
Option B on JDK 11 or later:
for a in "emsignrootcag1 [jdk]" "emsignrootcag2 [jdk]" "emsigneccrootcag3 [jdk]" "comodoaaaca [jdk]"; do
keytool -delete -cacerts -storepass changeit -alias "$a" || true # "does not exist" is fine
doneConfirm the aliases first, because they vary by vendor:
keytool -list -v -cacerts -storepass changeit | grep -iE 'Owner:.*(emSign|AAA Certificate)'
Distroless images have no shell, so use exec-form RUN:
FROM
USER root
RUN ["keytool","-delete","-cacerts","-storepass","changeit","-alias","emsigneccrootcag3 [jdk]"]
# repeat for each alias
USER nonroot3. Restart every JVM
That includes batch workers, queue consumers and serverless functions.
4. Verify, and verify again after every upgrade
A JDK or base-image upgrade can overwrite your edited cacerts or blocked.certs.
How Debian JDK, distroless and Kubernetes get their trust
On Debian-based images, the JDK doesn't use its own cacerts. It follows a symlink to a keystore that Debian builds from the OS certificate store.
Because of a long-standing Debian bug, updating ca-certificates and ca-certificates-java doesn't remove old entries from the Java keystore. OpenJDK's guidance is to rebuild it:
rm -f /etc/ssl/certs/java/cacerts
update-ca-certificates --freshDo this in your Dockerfile and rebuild. Don't patch running containers.
| Image family | Where cacerts comes from | Fixed by |
|---|---|---|
Debian/Ubuntu with openjdk-* packages | /etc/ssl/certs/java/cacerts | ca-certificates update plus a --fresh rebuild |
| Distroless Java on Debian 12/13 | Keystore baked into the image | A new upstream digest, then verify |
| Distroless on Debian 11 | Baked in; no Debian fix found | Move to a Debian 12 or 13 base |
| Temurin, Corretto, Zulu, Liberica images | The JDK's own lib/security/cacerts | A new JDK build, or interim A–D |
RHEL/UBI with java-*-openjdk | The OS-extracted Java keystore | ca-certificates errata |
Pinned digests (FROM …@sha256:…) are good practice, but they freeze the problem in. Bump them deliberately and re-scan.
In Kubernetes, trust doesn't only come from the image
- Mounted trust stores. A
cacertsor PEM bundle from a ConfigMap or Secret, set via-Djavax.net.ssl.trustStoreorJAVA_TOOL_OPTIONS. Rebuilding the image doesn't fix these. - cert-manager trust-manager. Bundles with
useDefaultCAs: truecopy a default CA package into every namespace. Check that your trust-manager version ships a package without these roots. - Init containers that build a trust store at pod start inherit whatever bundle they're given.
- Service mesh. Mesh-internal traffic uses the mesh CA, but egress TLS from a gateway or sidecar uses the proxy's own bundle.
Scanning and detecting the issue in an Docker image
How it works
- Pull the image for the platform you deploy (
linux/amd64by default). amd64 and arm64 images have separate trust stores. - Create a container without starting it (
docker create). Nothing runs, but its files can be copied out. That's why it works on distroless images, which have no shell. - Copy the JDK out and read its
releasefile to report the Java version. - Find the trust store the JVM actually reads. On Debian and distroless, the JDK's
lib/security/cacertsis a symlink to/etc/ssl/certs/java/cacerts. The scanner follows the link inside the image and copies the real file out. - Check the roots. It lists the store with
keytool -list -vand matches certificate subjects against the four eMudhra and AAA roots. A store it can't read, or one that comes back empty, makes the result inconclusive. - Run a live handshake inside the image. It mounts a small Java client into the container and runs it with the image's own
javaagainst public test hosts that serve certificates from the compromised chains. If the handshake succeeds, the image is vulnerable. If the certificate path is rejected, it's safe. Any other failure, such as DNS, a proxy or no network, is inconclusive. - Report a verdict and exit code for each image: vulnerable (1), clean (0) or inconclusive (2).
The static checks explain why an image is or isn't vulnerable. The live handshake is the proof, because it uses that image's exact JVM and settings.
What it doesn't see. The scanner checks the JDK's default trust store. It can't see a different store the app selects with -Djavax.net.ssl.trustStore in its entrypoint or config, or one mounted at deploy time from a ConfigMap or Secret. Check those separately.
Find the trust store the JVM actually reads. On Debian and distroless, cacerts is a symlink, so resolve it against the image rather than your machine:
link=$(readlink "$jdk/lib/security/cacerts" || true)
case "$link" in
"") store="$jdk_in_image/lib/security/cacerts" ;;
/*) store="$link" ;;
*) store="$jdk_in_image/lib/security/$link" ;;
esac
docker cp -qL "$id:$store" "$dir/cacerts" \
|| { echo "roots: ERROR - cannot copy $store"; status=inconclusive; }
Match roots by subject, not alias. Aliases differ across vendors; subjects don't. This is the same approach as OpenJDK's CheckCerts.java:
BAD='CN=emSign Root CA - G1|CN=emSign Root CA - G2|CN=emSign ECC Root CA - G3|CN=AAA Certificate Services'
listing=$(keytool -list -v -keystore "$dir/cacerts" -storepass changeit 2>&1) \
|| { echo "roots: ERROR - keytool failed"; status=inconclusive; }
[ "$(grep -c '^Alias name:' <<<"$listing")" -gt 0 ] \
|| { echo "roots: ERROR - empty store"; status=inconclusive; }
bad=$(grep -E '^Owner:' <<<"$listing" | grep -E "$BAD")
An empty or unreadable store is never reported as OK.
Live handshake, with three outcomes. Only a certificate-path rejection counts as safe:
} catch (javax.net.ssl.SSLHandshakeException e) {
boolean pathFail = false;
for (Throwable c = e; c != null; c = c.getCause())
if (c instanceof java.security.cert.CertPathValidatorException
|| c.getClass().getName().endsWith("SunCertPathBuilderException")) pathFail = true;
System.out.println((pathFail ? "OK " : "INCONCLUSIVE ") + u);
} catch (Exception e) {
System.out.println("INCONCLUSIVE " + u + " (" + e + ")"); // DNS, proxy, no egress
}
A few rules for this test:
- Compile it with
--release 8so it runs on any JDK. - Run it with the image's own
bin/java. - A corporate TLS-inspection proxy makes it report inconclusive, not clean.
Example output (illustrative):
===== example/java-app:1.4 (linux/amd64)
roots : FAIL - still trusted: CN=emSign ECC Root CA - G3
live TLS : VULNERABLE https://dv-g3b-emudhra.we-have-a-problem.com/
===== example/java-app:1.5
roots : OK - no eMudhra roots
live TLS : OK (certificate path rejected)
===== Summary =====
Clean (1) · Vulnerable (1) · Inconclusive (0)The script exits 0 when everything is clean, 1 when any image is vulnerable, and 2 when results are inconclusive, so CI can fail or warn.
Sources
As of 26 October 2026.
- OpenJDK advisory, 21 Oct 2026
- OpenJDK eMudhra guidance and CheckCerts.java
- Oracle Security Alert CVE-2026-91403
- Azul eMudhra workaround
- Red Hat RHSB-2026-012
- Debian DLA-4845-1
- BellSoft: fixing Java trust stores
- lilting.ch: testing CVE-2026-91403 on OpenJDK and Temurin
- JDK vendor builds roundup
- Community incident write-up (key details)
- The Hacker News
- Tanium
- SOCRadar (CERT-In warning)