Skip to content
AI-Native Engineering

TLS certificates on macOS after eMudhra: what to update in curl, Node, Python, Go, Java, Rust and Docker

macOS blocks eMudhra's intermediate CAs, but curl, Node, pip, Java and containers such as Docker use their own roots: what to fix and test.

Published Updated 11 min read
securityTLScertificatesDevOpsmacOS

This article is educational and does not replace a formal smart contract audit, legal advice, financial advice, or protocol-specific security review.

Eight eMudhra intermediate CAs, six for TLS and two for code signing, were generated with private keys that anyone can recover from the certificate itself; the six TLS ones can sign a valid certificate for any domain. Chrome and Firefox have blocked them since 17 October 2026, Microsoft since the 20th and, according to Dennis Jackson's page, Apple since the 22nd. Many development tools do not check those blocks and decide with their own list of roots.

On a Mac, Apple's block covers what uses the system verifier, such as Safari, Go and rustls-platform-verifier. It does not cover the system curl and python3, Node, pip, Java or Docker's Linux containers, which carry their own roots. Update each tool to its fixed version, rebuild whatever bundles the roots and test against the public server. The correct result is a certificate error; a 200 means that tool still trusts the intermediates.

Seven glass blocks on marble: five cobalt ones verified, a hand probing an amber one, and a clear one not yet reviewed.
Conceptual illustration.

What happened and why the browser is not enough

On 17 October 2026 a researcher warned the root programs of Apple, Google, Microsoft and Mozilla that the keys of eight eMudhra intermediates were weak. Google identifies emSign ECC Root CA - G3 as the root that signed the compromised intermediates, and there is also an alternative path through a cross-signature from Sectigo's AAA Certificate Services root, which older root lists still include.

Chrome blocked the public keys of those intermediates with a CRLSet update at 19:45 UTC the same day, 21:45 in Madrid. Mozilla completed its block with OneCRL at 22:59 UTC (00:59 on the 18th in Madrid), released Firefox 158.0.1 and removed the root from NSS in version 3.131.1. Google will remove the root from its store on the morning of Tuesday 27 October, Pacific time.

According to eMudhra, only one of the eight intermediates was in production and none issued end-entity certificates. Google and Dennis Jackson, of Mozilla, agree that there are no certificates from those intermediates in the Certificate Transparency (CT) logs, so browsers that require CT could not be attacked. The risk lies in programs that neither require CT nor check revocations: curl, language runtimes, pip and container images. For those programs to stop trusting the intermediates, their root list or their code has to be updated.

A compromised intermediate is not enough to attack. The attacker also needs to control the network, the DNS or a proxy between the tool and the server. The specific vectors and how to stop them are covered in a separate article.

How to read the test

Dennis Jackson, of Mozilla, maintains a test server with a chain signed by one of the affected intermediates, EM DV TLS CA - G3B. If a tool connects successfully, it still trusts it. If it returns a certificate error, it does not.

The exact message changes with the tool and the version: “unable to get local issuer certificate”, “certificate revoked”, “no valid chains built”, “CertDistrusted” or “PKIX path building failed” all mean the same thing. If the test gets no response or DNS fails, the result tells you nothing: run it again.

The same page documents variants of the server. The one with “-aaa-” goes through the Sectigo cross-signature and the one with “-noct-” uses a certificate that was never logged in CT. If a tool accepts the no-CT variant, it trusts the intermediates and also does not require CT.

Cloudflare points out an important nuance: the test only applies to the verification path being tested. A browser or curl rejecting the chain says nothing about an application that uses another TLS library.

curl

The macOS curl, like the system git, python3, perl, ruby and openssl, verifies against /etc/ssl/cert.pem without checking revocations, and that file has not been updated. Jackson's page publishes a procedure with sudo to remove the two affected roots from that file. It touches a system file, so read it in full and decide before applying it.

Homebrew's curl uses Homebrew's OpenSSL, which takes its roots from the ca-certificates formula. The fixed version is 2026-09-25_1 or later. Update them and test with that specific curl:

bash
brew update
brew upgrade ca-certificates curl
brew list --versions ca-certificates curl
"$(brew --prefix curl)/bin/curl" -v -o /dev/null https://dv-g3b-emudhra.we-have-a-problem.com/
echo "exit: $?"

The expected result is “SSL certificate problem: unable to get local issuer certificate” and exit code 60. An exit code of 0 with “HTTP/1.1 200 OK” means that curl still trusts the chain. curl does not require CT, so it is exactly the kind of client that Jackson's page describes.

Node.js

Node carries its own copy of Mozilla's roots inside the binary, so whatever macOS does has no effect on it. The fixed versions are 26.12.1, 24.22.1 and 22.23.4: they update the roots to NSS 3.131.1 and add rejection of the blocked public keys. If you use nvm, fnm or Volta, update every version you have installed, not just the active one.

bash
node --version
node <<'NODE'
const https = require('node:https');

const req = https.get({
  hostname: 'dv-g3b-emudhra.we-have-a-problem.com',
  timeout: 10000,
  rejectUnauthorized: true
}, res => {
  console.log('WARNING: TLS ACCEPTED:', res.statusCode);
  res.resume();
});

req.on('error', err => {
  console.log('REQUEST REJECTED:', err.code, err.message);
});

req.on('timeout', () => {
  console.log('INCONCLUSIVE: timeout');
  req.destroy();
});
NODE

With Node 24.22.1 the output is “REQUEST REJECTED: CERT_REVOKED certificate revoked”. With an earlier unfixed version, such as 20.19.2, you get “WARNING: TLS ACCEPTED: 200”. Also review NODE_EXTRA_CA_CERTS, NODE_TLS_REJECT_UNAUTHORIZED=0 and any rejectUnauthorized: false in the code, because they change what Node accepts.

Python, pip and uv

In Python there are two root lists to update. The interpreter's depends on how you installed it. pip's list ships inside pip: according to the official Python thread on the incident, pip always loads its own copy of certifi in addition to the system store, and options such as REQUESTS_CA_BUNDLE or --cert only add roots. The reliable fix is to update pip.

The minimum versions published by the Python Software Foundation are certifi 2026.10.19, pip 26.2.2 and uv 0.13.4. Python 3.15.1, 3.14.9 and 3.13.17 already include pip 26.2.2. With an old pip, the update itself travels with the old roots, so the PSF recommends pinning the package hash in the command. Run this inside each virtual environment:

bash
python3 -m pip --version
python3 -m pip install "pip @ https://files.pythonhosted.org/packages/3f/06/3e4420302363fbb71be3c7f35ce042f54bfca330e3e66b3a2e3c4cca579f/pip-26.2.2-py3-none-any.whl#sha256=fd7f4dfa8190e014013b9f08f3e973f4e4c26ffd179a204895533a7254813a70"
python3 -m pip install --upgrade "certifi>=2026.10.19"
python3 -m pip debug --verbose | grep certifi

The last line should show certifi==2026.10.19, the copy that ships with pip. If you use uv installed with its own installer, the PSF gives “UV_SYSTEM_CERTS=true uv self update”; if you installed it with Homebrew, “brew upgrade uv”.

To check both lists in an environment, run this test with that environment's python3:

python
import ssl
import socket
import certifi

host = "dv-g3b-emudhra.we-have-a-problem.com"

contexts = {
    "default": ssl.create_default_context(),
    "certifi": ssl.create_default_context(cafile=certifi.where()),
}

for name, ctx in contexts.items():
    try:
        with socket.create_connection((host, 443), timeout=10) as sock:
            with ctx.wrap_socket(sock, server_hostname=host):
                print(name, "WARNING: TLS ACCEPTED")
    except ssl.SSLCertVerificationError as exc:
        print(name, "TLS REJECTED:", exc.verify_message)
    except Exception as exc:
        print(name, "INCONCLUSIVE:", repr(exc))

With certifi 2026.10.19, the certifi line should say “TLS REJECTED: unable to get local issuer certificate”. The default line depends on the store that interpreter uses: if it says ACCEPTED, update the system store or the installer's.

Go

On macOS, Go delegates verification to the system APIs, so a Go program on the Mac inherits Apple's block. The problem lies in the binaries you build on the Mac for Linux, for example for a container: there Go uses its own verifier. Go 1.27.3 and 1.26.10 add a list of blocked public keys to crypto/x509 (CVE-2026-111943). Update Go with “brew upgrade go” or the official installer, and rebuild and redeploy every binary.

To test it, run this on Linux or inside the container where the binary runs:

bash
go version
mkdir -p /tmp/emudhra-go && cd /tmp/emudhra-go
cat > main.go <<'EOF'
package main

import (
	"fmt"
	"net/http"
	"time"
)

func main() {
	client := &http.Client{Timeout: 10 * time.Second}
	resp, err := client.Get("https://dv-g3b-emudhra.we-have-a-problem.com/")
	if err != nil {
		fmt.Println("TLS REJECTED:", err)
		return
	}
	resp.Body.Close()
	fmt.Println("WARNING: TLS ACCEPTED:", resp.Status)
}
EOF
go run main.go

With Go 1.27.3 on Linux, the output ends in “x509: no valid chains built”. With 1.27.2 on a root store that has not been updated, it ends in “200 OK”. An already built binary carries the version it was built with; “go version” followed by the binary's path shows it.

Java

JDKs released before 21 October do NOT block these intermediates by default, and neither does the one you already have installed if it is older. Update to a fixed version (Oracle or OpenJDK 27.0.1.0.2 / 25.0.5.0.2 / 21.0.13.0.2 / 17.0.21.0.2 / 11.0.33.0.2, Oracle JDK 8u511 b32, Corretto 25.0.5.0.9 / 21.0.13.0.10 / 8.512.08.1, or Homebrew openjdk / @21 / @17 at those same versions). If you cannot, copy lib/security/blocked.certs (on JDK 8, jre/lib/security/blacklisted.certs) from an already-fixed JDK, check that it contains the 16 SHA-256 fingerprints and restart your Java applications. Oracle also publishes the file at ops.java/emudhra, but the page was unavailable on 23 October.

Java uses its own root store, cacerts, inside each JDK, and does not consult the macOS block. If that cacerts includes emSign ECC Root CA - G3 or AAA Certificate Services, the JDK accepts the intermediates as long as they are not in its list of blocked certificates. The OpenJDK advisory of 21 October (CVE-2026-91403) lists versions 27.0.1, 25.0.5, 21.0.13, 17.0.21, 11.0.32.1 and 8u504, and earlier ones, as affected.

The fix is called JDK-8395142 in OpenJDK and JDK-8395555 in Oracle's release notes. It does not remove any root: it adds the 8 eMudhra intermediates and 8 certificates cross-signed by Sectigo to the blocklist. It has already shipped in Oracle JDK 27.0.1.0.2, 25.0.5.0.2, 21.0.13.0.2, 17.0.21.0.2, 11.0.33.0.2 and 8u511 b32, in Corretto 25.0.5.0.9, 21.0.13.0.10 and 8.512.08.1, and in Homebrew's openjdk, openjdk@21 and openjdk@17 formulae. As of 23 October, Homebrew's openjdk@11 and openjdk@8, Zulu and Temurin 8 and 11 still lack it. Temurin 21 and 25 do not include it either, but they reject the chain because they ship neither G3 nor AAA.

On Oracle JDK 8, the 8u511 build released on 20 October (1.8.0_511-b09) is still affected; the fixed build is 8u511 b32 (1.8.0_511-b32, 21 October). Check “java -version”: if it does not show “b32”, update. On OpenJDK 8 builds (Temurin, Zulu, etc.), 8u504 and earlier are affected and, as of 23 October, Temurin has not shipped a fix; Corretto 8.512.08.1 includes it.

The main path is to update. For Homebrew JDKs:

bash
/usr/libexec/java_home -V
brew list --versions | grep openjdk
brew upgrade openjdk
brew upgrade openjdk@21
brew upgrade openjdk@17
for f in openjdk openjdk@21 openjdk@17; do
  J="$(brew --prefix "$f")/bin/java"
  [ -x "$J" ] && echo "$f:" && "$J" -version
done

Update only the formulae you have installed. The first line does not list every JDK: only those linked in /Library/Java/JavaVirtualMachines or ~/Library/Java/JavaVirtualMachines. Keg-only Homebrew formulae such as openjdk@21 or openjdk@17 only show up with “brew list --versions” or “brew --prefix openjdk@21”, and each one has to be checked separately. The name is not enough: compare each JDK's real version with the list above. “brew list --versions” should show openjdk 27.0.1.0.2, openjdk@21 21.0.13.0.2 or openjdk@17 17.0.21.0.2; openjdk 27 or openjdk@21 21.0.13 are still affected. The final loop prints “java -version” for each formula. JDKs from an IDE or SDKMAN! are updated with their own installer.

This snippet saves the 16 SHA-256 fingerprints to /tmp/emudhra16.txt and counts how many of them are in the blocklist of each JDK on the Mac. In JDK 11 and later the file is called blocked.certs; in JDK 8, blacklisted.certs, inside the jre folder:

bash
cat > /tmp/emudhra16.txt <<'EOF'
0164D7CEAE5BB909C272AF151FF44B1177AE9B47D9424B94F1D9558C30934D19
42C4ACCD12C4F59597925E1A08A7A90102608543809308D50EC427E118BA1493
432EC25F0D28DE9006D97BF752F17BBB2CD41F669933FCB072D9A1148D0261F3
45A883F349AEDFD5F3A649B5498FFB121712BA8B448F3F2A91DBC5A766FCDDBE
4B2460A466F95FD40113BCDCCA2CEB29B3B093921DE45474DA7DF48874C630B1
579480AEA166290BD3C643589E795FC0D0E54E86505D180F1DFF99EFD3199512
5F1DED4DD590AF74631290CF073B122A2AAC6A1FA479778A0BD43579BD832699
62B7F9868263F72F6911FC78CEAF72C1979158B7368BF648683BB2AA39B02E72
7485B51C0ED192878FA1D2F948FB529AE1842CC5086C4B67F7BE5B2039DE9489
84112972B8E59D14560AE7E7119654474AAD119990CF3722BFC1E3B91615E1FD
98B896625C0C230ADEEC3EB2CF6AAD5001ECF9156A68644E82374CE5833BC25B
9E39DE2881273ACC225A149C72BA3E0F51424963669AB633EE1DC7DF5DB067B4
A496E23AC0CAB8155FC9B117B97EAE368EFCBB9041AEC8804D5A308D2D01C2D8
CB6E15165AC802E9B3F993F16C25D41F4E81C21EBDE71AE1B012AC00BF9C7C3F
E7568FE6E12331F4FD7DE61DA4C7559D0F8EB192A218D4741A3BA75184333838
FD9D5C2DBBD4995AB69B8D50C439D9527B00529199C2FD01B2174EB58F07021F
EOF

for JDK in /Library/Java/JavaVirtualMachines/*/Contents/Home \
           "$HOME"/Library/Java/JavaVirtualMachines/*/Contents/Home \
           /opt/homebrew/opt/openjdk*/libexec/openjdk.jdk/Contents/Home \
           /usr/local/opt/openjdk*/libexec/openjdk.jdk/Contents/Home; do
  [ -x "$JDK/bin/java" ] || continue
  F="$JDK/lib/security/blocked.certs"
  [ -f "$F" ] || F="$JDK/jre/lib/security/blacklisted.certs"
  N="$(grep -c -x -F -f /tmp/emudhra16.txt "$F" 2>/dev/null)"
  echo "$JDK: ${N:-0}/16 eMudhra fingerprints in $(basename "$F")"
done

A fixed JDK shows 16/16. With fewer than 16, it is still not protected.

To test a specific JDK, run this with its java and jshell on the PATH:

bash
java -version
cat > /tmp/emudhra-java.jsh <<'EOF'
import java.net.*;
import javax.net.ssl.*;

var url = URI.create("https://dv-g3b-emudhra.we-have-a-problem.com/").toURL();

try {
    var c = (HttpsURLConnection) url.openConnection();
    c.setConnectTimeout(10000);
    c.setReadTimeout(10000);
    System.out.println("WARNING: TLS ACCEPTED: " + c.getResponseCode());
} catch (SSLHandshakeException e) {
    System.out.println("TLS REJECTED: " + e.getMessage());
} catch (Exception e) {
    System.out.println("INCONCLUSIVE: " + e);
}
/exit
EOF
jshell /tmp/emudhra-java.jsh

With the blocklist applied, the output is “TLS REJECTED: (certificate_unknown) PKIX path validation failed”, followed by “Untrusted certificate: CN=EM DV TLS CA - G3B”. Temurin 21 and 25 show “PKIX path building failed”, because they do not have the root. The text varies between Java distributions; what matters is that “TLS ACCEPTED” does not appear.

If a JDK cannot be updated, such as openjdk@11 or openjdk@8, the fingerprints have to be added to its blocklist. The blocked.certs from an already-fixed JDK of the same version works, as do the 16 fingerprints in /tmp/emudhra16.txt, which match those in the JDK-8395142 PEM in OpenJDK. Oracle also publishes the file at ops.java/emudhra, but the page was unavailable on 23 October. The file starts with the line Algorithm=SHA-256, followed by one hexadecimal fingerprint per line. This snippet validates the source file, requires the 16 fingerprints, keeps the entries each JDK already has, saves a .pre-emudhra copy that it never overwrites and goes through the same JDKs as the check:

bash
SRC=/tmp/emudhra16.txt
(
  [ -s "$SRC" ] || { echo "ERROR: missing $SRC"; exit 1; }
  grep -Evx 'Algorithm=SHA-256|[0-9A-Fa-f]{64}' "$SRC" | grep -q . && { echo "ERROR: unexpected format"; exit 1; }
  [ "$(tr 'a-f' 'A-F' < "$SRC" | grep -c -x -F -f /tmp/emudhra16.txt)" = 16 ] || { echo "ERROR: missing eMudhra fingerprints"; exit 1; }
  for JDK in /Library/Java/JavaVirtualMachines/*/Contents/Home \
             "$HOME"/Library/Java/JavaVirtualMachines/*/Contents/Home \
             /opt/homebrew/opt/openjdk*/libexec/openjdk.jdk/Contents/Home \
             /usr/local/opt/openjdk*/libexec/openjdk.jdk/Contents/Home; do
    [ -x "$JDK/bin/java" ] || continue
    F="$JDK/lib/security/blocked.certs"
    [ -f "$F" ] || F="$JDK/jre/lib/security/blacklisted.certs"
    [ "$(head -n 1 "$F" 2>/dev/null)" = "Algorithm=SHA-256" ] || { echo "SKIPPED: $JDK"; continue; }
    [ -e "$F.pre-emudhra" ] || cp -p "$F" "$F.pre-emudhra" || exit 1
    {
      echo "Algorithm=SHA-256"
      { tail -n +2 "$F"; grep -Ex '[0-9A-Fa-f]{64}' "$SRC"; } | tr 'a-f' 'A-F' | sort -u
    } > "$F.tmp" && cat "$F.tmp" > "$F" && rm "$F.tmp" || exit 1
    echo "$JDK: $(grep -c -x -F -f /tmp/emudhra16.txt "$F")/16; entries before: $(($(wc -l < "$F.pre-emudhra") - 1)); after: $(($(wc -l < "$F") - 1))"
  done
  echo "Restart all running Java applications: blocked.certs is only read when the JVM starts."
)

To use Oracle's file or the one from a fixed JDK, change the first line to its path. Every JDK should end up at 16/16. JDKs in /Library/Java/JavaVirtualMachines need administrator permissions: run the snippet in a shell opened with sudo -s. Java reads the list only at startup, so you have to restart the Java applications, the IDE and any servers that are running. A Homebrew update replaces the file with the new version's: if that version is fixed, nothing is lost; if not, run the snippet again.

The OpenJDK advisory proposes another temporary alternative: enabling revocation checking with “-Dcom.sun.net.ssl.checkRevocation=true -Dcom.sun.security.enableCRLDP=true”. Deleting the roots with keytool -delete is not what the fix does; it keeps the roots and blocks the 16 certificates. Removing AAA Certificate Services also affects everything that depends on it, and Dennis Jackson's page says that distrusting these roots is not recommended for ordinary users.

Rust

In Rust the fix does not depend on the compiler version but on each project's Cargo.lock. rustls-webpki 0.103.16 rejects the blocked keys (RUSTSEC-2026-0382) and webpki-roots 1.0.10 removes the emSign ECC Root CA - G3 root. On macOS, rustls-platform-verifier uses the system verifier, but on Linux it uses webpki, so the risk is once again in the binaries you deploy in containers. In each project:

bash
cargo tree -i rustls-webpki
cargo update -p rustls-webpki
cargo tree -i rustls-webpki

After the update, “cargo tree” should show rustls-webpki v0.103.16 or later. If the project uses webpki-roots, do the same with “cargo update -p webpki-roots” up to 1.0.10. Rebuild and redeploy. If you use rustup, update it to 1.29.2 with “rustup self update”.

Docker

Docker Desktop 4.96.2, released on 22 October, removes trust in the compromised intermediates. That covers the connections of the Docker CLI and daemon, but not the images: each image carries its own root list and its own versions of Python, Node, Java or Go.

Update Docker Desktop from the app or with “brew upgrade --cask docker-desktop” and rebuild the images without cache, so they do not reuse a layer with the old roots:

bash
docker version --format '{{.Server.Platform.Name}}'
docker build --pull --no-cache -t my-image:emudhra .
docker run --rm my-image:emudhra python3 -m pip --version

The last line is an example for images with Python: inside the image you should see pip 26.2.2 or later. For the others, run the test for the relevant language inside the container. If the base image has not yet published a fixed version, update the distribution's ca-certificates package or the runtime in your own Dockerfile.

Checklist

  • Homebrew: ca-certificates 2026-09-25_1 or later, and Homebrew's curl tested against the server.
  • System curl, git and python3: the option for /etc/ssl/cert.pem decided and documented.
  • Node: 26.12.1, 24.22.1 or 22.23.4 in every installed version.
  • Python: pip 26.2.2 and certifi 2026.10.19 in every virtual environment; uv 0.13.4.
  • Go: 1.27.3 or 1.26.10, and binaries rebuilt and redeployed.
  • Java: every JDK, including keg-only Homebrew formulae, on a fixed version (the full number, not just the name) or, if that is not possible, with the 16 fingerprints in blocked.certs (16/16) or revocation checking enabled, and Java applications restarted.
  • Rust: rustls-webpki 0.103.16 in every Cargo.lock, and binaries rebuilt.
  • Docker: Desktop 4.96.2 or later, and images rebuilt without cache.
  • Variables and options that disable verification or add roots reviewed.

What a rejection does not prove

A certificate error in the test indicates that this tool, in this version, no longer trusts the intermediates. It says nothing about another tool on the same Mac, another virtual environment or a container. Nor does it replace checking whether any connection may have gone through a network you do not control while the problem was still open.

To close the eMudhra incident on macOS and in CI, update the 7 tools one by one and keep the output of each test with the date and the version. It is the simplest evidence that the fix was applied where it matters.

Source registry

Sources consulted on 23 October 2026. Run on Linux (Debian 13), with the output saved: the curl, Node, Python, Go, Java and Rust tests, the pip update with hash, and the 16/16 check and the blocked.certs merge on JDK copies in test paths. Checked read-only on a real Mac (macOS 27.0.1, arm64) on 23 October: /usr/libexec/java_home -V, /Library/Java/JavaVirtualMachines and brew list --versions. Not run on macOS: brew upgrade, brew --prefix, sudo -s, the Homebrew paths in the scripts, and /etc/ssl/cert.pem. Not run: Docker.

Need help applying this to your launch or workflow?

Request a technical review and we will map the safest first step.

Related posts