The eMudhra CA compromise: is your JVM still trusting it?

Eight eMudhra CA keys were weak enough to brute-force, and your JVM may still trust them. Here's what happened, who's exposed, the right fix for vendor JDKs, Debian and distroless images, and a scanner to check your containers....

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) keyCompromised intermediate key
What an attacker can forgeOne domainAny domain, plus signed code for the two code-signing CAs
Who trusts the forgeryClients of that one siteEvery client whose trust store holds the root
How it's fixedRevoke one certificateRevocation, 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

Timeline, Apr 2025 to 27 Oct 2026
DateEvent
Apr 2025Intermediates issued
17 Oct 2026Reported; Chrome and Firefox block them
18–19 OcteMudhra revokes them
19–20 OctApple and Microsoft block them
20 OctDebian ca-certificates fix; Mozilla NSS removes the G3 root
21 OctOracle, OpenJDK and Azul ship out-of-band JDK builds
24 OctOpenJDK updates its guidance (see below)
27 OctChrome'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

JVM → attacker with forged cert → partner API; callouts "chain trusted ✓", "CT ✗", "revocation ✗"
  1. 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.
  2. They present a certificate for api.partner.com, signed with a recovered intermediate key.
  3. The JVM finds a trusted root and completes the handshake.
  4. 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 situationPriority
JVM calls third parties over HTTPS (payment gateways, banks, SaaS, identity providers)Patch now
JVM verifies signed JARs, plugins or artifacts at runtimePatch now
JVM calls cloud provider APIs over the public internetHigh
JVM calls only internal services with a private CA and a custom trust storeMedium: check the custom store doesn't contain these roots
Service only accepts inbound HTTPS, terminated at a load balancerLower, but most services make some outbound call
Your own site's certificate chains to an emSign rootReplace the certificate
"Am I affected?" flowchart

You are affected if all three hold:

  1. The trust store contains one of the bad roots.
  2. blocked.certs doesn't block the compromised certificates.
  3. 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

SourceWhat it says
OpenJDK advisory, 21 OctCVE-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/emudhraPrefer a vendor update. Workarounds: remove four roots, or turn on revocation checking. Checker: CheckCerts.java
Oracle Security AlertOut-of-band Oracle JDK builds, 21 Oct
Azul workaroundReplacement blocked.certs with a published SHA-256
Red Hat RHSB-2026-012Rated Critical; fix via ca-certificates errata; rebuild containers
Debian DLA-4845-1 / DSA-6572-1Roots 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):

Vendor811172125
Oracle8u511 b3211.0.33.0.217.0.21.0.221.0.13.0.225.0.5.0.2
Azul Zulu8u51211.0.3317.0.2121.0.1325.0.5
Amazon Corretto8.512.08.111.0.33.0.917.0.21.0.1021.0.13.0.1025.0.5.0.9

Corretto's changelog doesn't mention eMudhra. Temurin, Microsoft and Red Hat builds were unconfirmed at that date.

Two fix paths. Vendor JDK (blocklist, roots kept) vs distro (roots removed), then restart and verify

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-*, RHEL java-*-openjdk): update ca-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

OptionWhat it doesWatch out for
A. Vendor blocked.certsReplace lib/security/blocked.certs (blacklisted.certs on JDK 8) with the vendor's fileCheck its SHA-256. Never edit it by hand
B. Remove four rootskeytool -delete emSign Root CA G1, G2, ECC G3 and AAA Certificate ServicesBreaks legitimate chains; aliases differ by vendor
C. Trimmed trust storeCopy cacerts, remove the roots in the copy, run with -Djavax.net.ssl.trustStoreEvery JVM needs the flag
D. Revocation checking-Dcom.sun.net.ssl.checkRevocation=true -Dcom.sun.security.enableCRLDP=trueAdds 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
done

Confirm 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 nonroot

3. 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.

Cert flow. NSS → ca-certificates → update-ca-certificates → ca-certificates-java → /etc/ssl/certs/java/cacerts ← JDK symlink; blocked.certs branch; red "--fresh" callout; Kubernetes band below

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 --fresh

Do this in your Dockerfile and rebuild. Don't patch running containers.

Image familyWhere cacerts comes fromFixed by
Debian/Ubuntu with openjdk-* packages/etc/ssl/certs/java/cacertsca-certificates update plus a --fresh rebuild
Distroless Java on Debian 12/13Keystore baked into the imageA new upstream digest, then verify
Distroless on Debian 11Baked in; no Debian fix foundMove to a Debian 12 or 13 base
Temurin, Corretto, Zulu, Liberica imagesThe JDK's own lib/security/cacertsA new JDK build, or interim A–D
RHEL/UBI with java-*-openjdkThe OS-extracted Java keystoreca-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 cacerts or PEM bundle from a ConfigMap or Secret, set via -Djavax.net.ssl.trustStore or JAVA_TOOL_OPTIONS. Rebuilding the image doesn't fix these.
  • cert-manager trust-manager. Bundles with useDefaultCAs: true copy 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

Scanner pipeline. Pull → copy trust store out → roots / blocklist / live TLS → verdict (red/green/amber)

How it works

  1. Pull the image for the platform you deploy (linux/amd64 by default). amd64 and arm64 images have separate trust stores.
  2. 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.
  3. Copy the JDK out and read its release file to report the Java version.
  4. Find the trust store the JVM actually reads. On Debian and distroless, the JDK's lib/security/cacerts is a symlink to /etc/ssl/certs/java/cacerts. The scanner follows the link inside the image and copies the real file out.
  5. Check the roots. It lists the store with keytool -list -v and 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.
  6. Run a live handshake inside the image. It mounts a small Java client into the container and runs it with the image's own java against 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.
  7. 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 8 so 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):

Terminal screenshot of the output below
===== 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.