Firefox and Chrome blocked the eight compromised eMudhra intermediate CAs on 17 October, the same day they were reported. Apple followed on the 19th and Microsoft on the 20th.
This morning I pointed requests at one of the public test hosts that serves a certificate from a compromised intermediate. It returned 200. Then I tried fetch() in Node 22. Also 200.
Your browser is fine. Your backend might not be. Here is what happened, why servers are the gap, and the 151-line script I used to check my own machines, with the output pasted as it came out.
What actually broke
The disclosure site lists eight intermediate CA certificates from eMudhra, created in April 2025. Six issue TLS certificates (DV, OV and EV, generations G3B and G3C) and two issue code-signing certificates. Each one was also cross-signed by Sectigo's AAA Certificate Services root, so there are 16 certificates in total covering 8 keys.
The keys were generated incorrectly, so the private keys can be recovered from the public certificates. The reporter proved it by signing a message with each of the eight keys. The site says there are no Certificate Transparency log entries for these intermediates since April 2025, and concludes that browsers which enforce CT could not have been attacked. That protection stops at the browser.
Two trust paths matter:
| Path | Anchor | Who still ships the anchor |
|---|---|---|
leaf, compromised intermediate, emSign Root TLS CA - G3
|
emSign ECC Root CA - G3 (cross-signer) |
Included by Apple, Chrome, Microsoft and Mozilla |
| leaf, compromised intermediate (AAA cross-sign) | AAA Certificate Services |
Apple and Microsoft; removed by Chrome and Mozilla |
Firefox and Chrome distrust the intermediates through OneCRL, CRLite and CRLSets, and Apple and Microsoft pushed their own blocks. Python, Node, Go and most SDKs do none of that. They load a static bundle of roots, they usually skip revocation checks, and none of them enforce CT. If the anchor is in the bundle, the bad intermediate chains to it.
The script
It does four things, all with the standard library plus the openssl CLI:
-
stores: finds CA bundles on the machine (system paths and certifi) and flags any that contain the four anchors, matched by SHA-256 of the DER certificate. -
client: opens a real TLS handshake to two test hosts, once with Python's default context and once with certifi's bundle. A successful handshake means that runtime trusts a compromised intermediate. -
hosts: pulls the chain your own endpoints serve and flags any certificate whose public key matches one of the eight compromised keys. It matches on the SubjectPublicKeyInfo hash, so it also catches a reissued certificate that reuses a bad key. -
verify: runs an offlineopenssl verifyof the 12 captured chains from the site'schains.zipagainst any bundle you name.
Exit code 1 means something failed, so it can gate a CI job.
#!/usr/bin/env python3
"""emudhra_check.py: find trust in the 8 compromised eMudhra intermediates.
python3 emudhra_check.py stores # CA bundles on this box
python3 emudhra_check.py client # does this Python trust them?
python3 emudhra_check.py hosts api.example.com # what do your endpoints serve?
python3 emudhra_check.py verify chains/ # offline, with chains.zip
Standard library only (plus the openssl CLI for `hosts` and `verify`).
Hashes come from impacted-intermediates.json and roots.json at
https://emudhra.we-have-a-problem.com/
"""
import glob, hashlib, os, re, socket, ssl, subprocess, sys
# SHA-256 of the SubjectPublicKeyInfo: one per compromised key (16 certs, 8 keys)
BAD_SPKI = {
"ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105": "EM DV TLS CA - G3B",
"43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b": "EM OV TLS CA - G3B",
"e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41": "EM EV TLS CA - G3B",
"49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f": "EM DV TLS CA - G3C",
"63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8": "EM OV TLS CA - G3C",
"5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0": "EM EV TLS CA - G3C",
"ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6": "EM OV CS CA - G3B (code signing)",
"92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30": "EM OV CS CA - G3C (code signing)",
}
# SHA-256 of the whole DER cert: roots that anchor those intermediates
ANCHORS = {
"86a1ecba089c4a8d3bbe2734c612ba341d813e043cf9e8a862cd5c57a36bbe6b": "emSign ECC Root CA - G3",
"d7a7a0fb5d7e2731d771e9484ebcdef71d5f0c3e0a2948782bc83ee0ea699ef4": "AAA Certificate Services",
"7dd78d5f4f13459a83dff9abbba62edbaf6f2d102bf257fd712f4d9f2746ed8d": "emSign Root TLS CA - G3",
"f2edaaff3d9b701fb4d14c9f285f8c0bad90fc3bcc1fa07d47f4718edaba34a5": "emSign Root CS CA - G3",
}
TEST_HOSTS = ["dv-g3b-emudhra.we-have-a-problem.com", # via emSign roots
"dv-g3b-aaa-emudhra.we-have-a-problem.com"] # via AAA cross-sign
BUNDLES = ["/etc/ssl/certs/ca-certificates.crt", "/etc/pki/tls/certs/ca-bundle.crt",
"/etc/ssl/cert.pem", "/etc/ssl/ca-bundle.pem",
"/opt/homebrew/etc/ca-certificates/cert.pem", "/usr/local/etc/openssl@3/cert.pem"]
PEM_RE = re.compile(r"-----BEGIN CERTIFICATE-----.+?-----END CERTIFICATE-----", re.S)
def tlv(buf, i):
"""Return (start_of_value, end_of_value) for the DER element at i."""
n = buf[i + 1]
if n < 0x80:
return i + 2, i + 2 + n
k = n & 0x7F
n = int.from_bytes(buf[i + 2:i + 2 + k], "big")
return i + 2 + k, i + 2 + k + n
def spki_sha256(der):
"""Walk Certificate -> TBSCertificate -> 7th field (SubjectPublicKeyInfo)."""
s, _ = tlv(der, 0) # Certificate SEQUENCE
i, _ = tlv(der, s) # TBSCertificate SEQUENCE
if der[i] == 0xA0: # optional [0] version
i = tlv(der, i)[1]
for _ in range(5): # serial, sigAlg, issuer, validity, subject
i = tlv(der, i)[1]
return hashlib.sha256(der[i:tlv(der, i)[1]]).hexdigest()
def certs_in(text):
return [ssl.PEM_cert_to_DER_cert(p) for p in PEM_RE.findall(text)]
def classify(der):
full, spki = hashlib.sha256(der).hexdigest(), spki_sha256(der)
return ANCHORS.get(full), BAD_SPKI.get(spki)
def cmd_stores(extra):
paths = BUNDLES + extra
try:
import certifi
paths.append(certifi.where())
except ImportError:
pass
paths.append(ssl.get_default_verify_paths().cafile or "")
risky = 0
for p in dict.fromkeys(x for x in paths if x and os.path.isfile(x)):
found = [a for a, b in map(classify, certs_in(open(p, errors="ignore").read())) if a or b]
names = sorted({f for f in found if f})
risky += bool(names)
print(f"{'RISK' if names else 'ok '} {p}: {', '.join(names) or 'no eMudhra anchors'}")
return risky
def cmd_client(_):
ctxs = {"ssl default": ssl.create_default_context()}
try: # requests, httpx, boto3 and most SDKs trust certifi, not the OS store
import certifi
ctxs[f"certifi {certifi.__version__}"] = ssl.create_default_context(cafile=certifi.where())
except ImportError:
pass
bad = 0
for label, ctx in ctxs.items():
for h in TEST_HOSTS:
try:
with socket.create_connection((h, 443), timeout=10) as s:
with ctx.wrap_socket(s, server_hostname=h):
pass
bad += 1
print(f"FAIL [{label}] {h}: handshake OK, compromised intermediate trusted")
except ssl.SSLCertVerificationError as e:
print(f"ok [{label}] {h}: rejected ({e.verify_message})")
except OSError as e:
print(f"skip [{label}] {h}: {e}")
return bad
def served_chain(host):
name, _, port = host.partition(":")
out = subprocess.run(["openssl", "s_client", "-showcerts", "-connect", f"{name}:{port or 443}",
"-servername", name], input=b"", capture_output=True, timeout=20)
return certs_in(out.stdout.decode(errors="ignore"))
def cmd_hosts(hosts):
bad = 0
for h in hosts:
try:
chain = served_chain(h)
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
print(f"skip {h}: {e}")
continue
hits = [b for _, b in map(classify, chain) if b]
bad += bool(hits)
print(f"{'FAIL' if hits else 'ok '} {h}: {len(chain)} certs served"
+ (f", compromised: {', '.join(hits)}" if hits else ""))
return bad
def cmd_verify(args):
folder, bundles = args[0], args[1:] or [None]
bad = 0
for f in sorted(glob.glob(os.path.join(folder, "*.pem"))):
for b in bundles:
cmd = ["openssl", "verify"] + (["-CAfile", b] if b else []) + ["-untrusted", f, f]
r = subprocess.run(cmd, capture_output=True, text=True)
ok = r.returncode == 0
bad += ok
print(f"{'FAIL' if ok else 'ok '} {os.path.basename(f)} vs {b or 'openssl default'}"
+ ("" if not ok else ": chain still verifies"))
return bad
if __name__ == "__main__":
cmds = {"stores": cmd_stores, "client": cmd_client, "hosts": cmd_hosts, "verify": cmd_verify}
if len(sys.argv) < 2 or sys.argv[1] not in cmds:
sys.exit(__doc__)
sys.exit(1 if cmds[sys.argv[1]](sys.argv[2:]) else 0)
What it found on my machine
I ran it on an Ubuntu 22.04 VM with Python 3.10, Node 22.23.2 and the ca-certificates package at version 20260601.
$ python3 emudhra_check.py stores
ok /etc/ssl/certs/ca-certificates.crt: no eMudhra anchors
RISK /usr/local/lib/python3.10/dist-packages/certifi/cacert.pem: emSign ECC Root CA - G3
$ python3 emudhra_check.py client
ok [ssl default] dv-g3b-emudhra.we-have-a-problem.com: rejected (unable to get local issuer certificate)
ok [ssl default] dv-g3b-aaa-emudhra.we-have-a-problem.com: rejected (unable to get local issuer certificate)
FAIL [certifi 2026.07.22] dv-g3b-emudhra.we-have-a-problem.com: handshake OK, compromised intermediate trusted
ok [certifi 2026.07.22] dv-g3b-aaa-emudhra.we-have-a-problem.com: rejected (unable to get local issuer certificate)
$ python3 -c "import requests; print(requests.get('https://dv-g3b-emudhra.we-have-a-problem.com/').status_code)"
200
$ python3 emudhra_check.py hosts dv-g3b-emudhra.we-have-a-problem.com ov-g3c-aaa-emudhra.we-have-a-problem.com axionry.com
FAIL dv-g3b-emudhra.we-have-a-problem.com: 3 certs served, compromised: EM DV TLS CA - G3B
FAIL ov-g3c-aaa-emudhra.we-have-a-problem.com: 2 certs served, compromised: EM OV TLS CA - G3C
ok axionry.com: 4 certs served
The OS store was clean. The bundle that requests, httpx, boto3 and most Python SDKs actually use was not. That split is the whole story: an apt upgrade does nothing for a certifi pinned in your lockfile or baked into a Docker image last summer.
Node was the same:
$ node -e "fetch('https://dv-g3b-emudhra.we-have-a-problem.com/').then(r => console.log(r.status))"
200
$ node --use-system-ca -e "fetch('https://dv-g3b-emudhra.we-have-a-problem.com/').then(r => console.log(r.status))"
200
$ node --use-openssl-ca -e "fetch('https://dv-g3b-emudhra.we-have-a-problem.com/').then(r => console.log(r.status))"
TypeError: fetch failed
[cause]: Error: unable to get local issuer certificate
code: 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY'
(Stack trace trimmed.)
Node's compiled-in root list includes emSign ECC Root CA - G3. --use-system-ca adds the OS store on top of that list, so it does not help. --use-openssl-ca replaces the bundled list with the OpenSSL store, which on this VM did not contain the anchor.
The fixes I verified
Python. Upgrade certifi. Version 2026.10.19 drops emSign ECC Root CA - G3 from the bundle. I downloaded both versions and ran the offline check against each:
$ python3 emudhra_check.py verify chains/ certifi-2026.07.22/cacert.pem | cut -c1-4 | sort | uniq -c
6 FAIL
6 ok
$ python3 emudhra_check.py verify chains/ certifi-2026.10.19/cacert.pem | cut -c1-4 | sort | uniq -c
12 ok
The 6 failures on the old bundle are exactly the six chains that run through the emSign path. After pip install -U certifi, the client check rejected both test hosts.
Node. The disclosure page lists Node as pending. Until a release lands, NODE_OPTIONS=--use-openssl-ca makes Node use the OS store. Run stores first so you know that store is clean, and test it in staging, because any internal CA you added only to Node's extra certs will need to be in the OS store too.
Go. The page lists Go 1.27.3 and 1.26.10 as patched. Rebuild and redeploy; a static binary keeps whatever it was built with.
Containers. Rebuild images, do not just restart them. Run stores and client inside the image as a CI step.
macOS curl. /usr/bin/curl reads /etc/ssl/cert.pem without revocation checks. The disclosure page has a backup-then-edit workaround for that file.
Honest caveats
- The script only knows the 16 certificates and 4 anchors published so far. It is a check for this incident, not a general trust audit.
- Removing
emSign ECC Root CA - G3also distrusts legitimate certificates that chain to it. The updated certifi made that call already; if you remove it yourself, check which of your partners' endpoints use emSign certificates first (thehostscommand shows the chain). -
openssl verifydoes not check revocation unless you ask it to, soverifymeasures trust only. - The OS store being clean on my VM says nothing about yours. Run it.
The pattern repeats with every CA incident: browsers get patched in hours, server-side trust stores in months, because nobody owns them. I check exactly this kind of thing when I do technical due diligence on a codebase before a round, and it is part of the operating baseline I set up as a fractional CTO at Axionry. If you would rather someone else run it across your fleet, that is what Axionry is for.
Sources: eMudhra intermediate CA compromise disclosure, with impacted-intermediates.json, roots.json and chains.zip, Mozilla bug 2083449, certifi on PyPI, Node.js CLI docs for --use-openssl-ca and --use-system-ca.
Top comments (1)
Matching on the SubjectPublicKeyInfo hash instead of the certificate is the right call, since a reissued cert with a reused key would slip past a fingerprint check. The practical follow-up for most teams is that the gap is the runtime's bundle plus no revocation checking, so the fix is a deploy of an updated trust store or an explicit distrust entry, not just a scan. It also helps to run the client check from inside each container image, because the system bundle, certifi and a language-bundled store can disagree on the same host. A pinned image that has not been rebuilt in months is the likely place for this to linger.