Takeaway: on a patched Ubuntu 24.04 box, the eMudhra clean-up is not one package update. We counted seven separate trust stores that outbound HTTPS calls can read, and after updating everything that apt, pip and npm offered, two still carried an emSign root: composer 2.8.12’s bundled cacert.pem (which still holds the compromised emSign ECC Root CA - G3) and Node 22.23.4’s compiled-in list (which still holds the RSA emSign Root CA - G1). The 40-line ca-audit.sh below prints the whole table in two seconds. Versions checked on 29 Oct 2026, in a Linux container with Ubuntu 24.04, ca-certificates 20260816ubuntu0.24.04.1, curl 8.5.0, PHP 8.3.6, composer 2.8.12, Node 22.23.4, Python 3.13 with certifi 2026.10.18, pip 26.2.1 and Go 1.24.7.
Why we did this: our WordPress sites (with the Paperkite plugin) and the Laravel 13 apps on the CloudPanel VPS make outbound HTTPS calls all day: wp_remote_get for update checks, Composer and npm during deploys, a Go sidecar for image work, and Node under PM2. Each of those reads its own idea of “trusted roots”. This post is the inventory we wrote before touching the VPS, so the fix there is a checklist and not guesswork.
What happened, in four lines
On 17 Oct 2026 a researcher found that eight eMudhra (emSign) intermediate CA keys generated in April 2025 were P-384 private keys with values between 1 and 255, recoverable from the public certificates. Chrome blocked the six TLS intermediates by CRLSet the same evening, Firefox by OneCRL, Apple on 19 Oct, Microsoft on 20 Oct, and NSS 3.131.1 removed emSign ECC Root CA - G3 from Mozilla’s root store entirely. Sectigo had cross-signed the same keys under AAA Certificate Services and revoked those cross-signs on 19 Oct. On 25 Oct eMudhra said it will leave the public TLS business. The tool-by-tool fix list we worked from is the lilting.ch write-up (21 Oct, updated 26 Oct); Node’s October security release notes list “ca-cert (eMudhra Intermediate CA Compromise)” against 22.x, 24.x and 26.x; Go tracked its denylist of the eight intermediates as CVE-2026-111943, fixed in 1.26.10 and 1.27.3.
Browsers were covered within hours. Servers are the slow part, because nothing on a server checks CRLSet or OneCRL. A server client only stops trusting a chain when its own root list changes, or when the runtime ships a hardcoded denylist.
The seven stores on one Ubuntu box
This is what ca-audit.sh found on our container. “ECC G3” is the root of the compromised chain; “RSA G1” is eMudhra’s older RSA root, which eMudhra says is unaffected but which Mozilla’s current data no longer ships either.

Output of bash ca-audit.sh in the Ubuntu 24.04 container on 29 Oct 2026. The five extra certificates in the system bundle (122 against certifi’s 117) are the sandbox’s own inspection CAs, not Mozilla roots.
Reading the table top to bottom:
- ca-certificates 20260816ubuntu0.24.04.1 is the fixed Ubuntu package and it carries no emSign root at all. Everything that reads
/etc/ssl/certs/ca-certificates.crtinherits that: system curl, PHP’sopensslextension (/usr/lib/ssl/cert.pemis a symlink to it), PHP’s curl extension whencurl.cainfois unset, and thereforewp_remote_get,file_get_contents('https://...')and WordPress cron. - composer 2.8.12 ships composer/ca-bundle 1.5.8 inside the phar, with Mozilla data dated 12 Aug 2025: 146 certificates including
emSign ECC Root CA - G3,emSign Root CA - G1and the two USemSign ... C1/C3roots. composer/ca-bundle 1.4.16 and 1.6.0 are the fixed releases. - Node 22.23.4 is a fixed release and does not carry ECC G3, but its compiled-in list still has
emSign Root CA - G1, plus Atos TrustedRoot 2011 and Chunghwa’s ePKI root, none of which are in certifi 2026.10.18 or the Ubuntu package any more; it also lacks the SECOM 2024 and Telia v3 roots those two have. Node’s list is a snapshot of NSS taken at release time, not the live Mozilla data. - certifi 2026.10.18 (the fixed version) and pip 26.2.1’s vendored certifi 2026.06.17 both show no emSign root. pip 26.2.2 is the release that bumped its vendored copy, so on this box the older vendored copy happened to be clean already; check yours rather than assuming.
- Go 1.24.7 has no bundled roots on Linux.
crypto/x509reads the same/etc/ssl/certs/ca-certificates.crt(orSSL_CERT_FILEif set), so the Ubuntu package update removes the root for Go too. What Go 1.24.7 does not have is the CVE-2026-111943 denylist that 1.26.10 and 1.27.3 add, which rejects the eight intermediates even when a chain reaches a still-trusted root through the Sectigo cross-sign.
The script
#!/usr/bin/env bash
# ca-audit.sh - which trust store does each tool read, and does it still hold an emSign root?
set -u
has() { # $1 pem file, $2 CN substring -> YES/no
python3 - "$1" "$2" <<'PY'
import re,subprocess,sys
pem=open(sys.argv[1]).read()
for c in re.findall(r"-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----", pem, re.S):
s=subprocess.run(["openssl","x509","-noout","-subject"],input=(c+"\n").encode(),capture_output=True).stdout.decode()
if sys.argv[2] in s: print("YES"); sys.exit()
print("no")
PY
}
count() { grep -c "BEGIN CERT" "$1"; }
row() { printf "%-44s %5s %-7s %-7s\n" "$1" "$3" "$4" "$5"; printf " %s\n" "$2"; }
SYS=/etc/ssl/certs/ca-certificates.crt
row "ubuntu ca-certificates $(dpkg-query -W -f='${Version}' ca-certificates)" "$SYS" \
"$(count $SYS)" "$(has $SYS 'emSign ECC Root CA - G3')" "$(has $SYS 'emSign Root CA - G1')"
row "php $(php -r 'echo PHP_VERSION;') openssl ext" \
"$(php -r 'echo openssl_get_cert_locations()["default_cert_file"];') -> $(readlink -f /usr/lib/ssl/cert.pem)" - - -
row "php curl.cainfo / openssl.cafile" \
"$(php -r 'echo (ini_get("curl.cainfo") ?: "(unset)")." / ".(ini_get("openssl.cafile") ?: "(unset)");')" - - -
cp "$(command -v composer)" /tmp/composer.phar
php -r 'copy("phar:///tmp/composer.phar/vendor/composer/ca-bundle/res/cacert.pem","/tmp/composer-cacert.pem");'
row "composer $(composer --version | awk '{print $3}') bundled ca-bundle" "phar://composer/vendor/composer/ca-bundle/res/cacert.pem" \
"$(count /tmp/composer-cacert.pem)" "$(has /tmp/composer-cacert.pem 'emSign ECC Root CA - G3')" "$(has /tmp/composer-cacert.pem 'emSign Root CA - G1')"
node -e 'const t=require("tls").rootCertificates;const {X509Certificate}=require("crypto");
const s=t.map(c=>new X509Certificate(c).subject);const h=n=>s.some(x=>x.includes(n))?"YES":"no";
console.log(("node "+process.version+" bundled roots").padEnd(44)+String(t.length).padStart(5)+" "+h("emSign ECC Root CA - G3").padEnd(8)+h("emSign Root CA - G1"));
console.log(" tls.rootCertificates (compiled in)")'
CF=$(python3 -c 'import certifi;print(certifi.where())')
row "python certifi $(python3 -c 'import certifi;print(certifi.__version__)')" "$CF" \
"$(count $CF)" "$(has $CF 'emSign ECC Root CA - G3')" "$(has $CF 'emSign Root CA - G1')"
PF=$(python3 -c 'import pip._vendor.certifi as c;print(c.where())')
row "pip $(pip --version | awk '{print $2}') vendored certifi" "$PF" \
"$(count $PF)" "$(has $PF 'emSign ECC Root CA - G3')" "$(has $PF 'emSign Root CA - G1')"
row "go $(go version | awk '{print $3}')" "no bundled roots on linux; reads $SYS (or SSL_CERT_FILE)" - - -
It needs openssl, python3, php, node and go to be on the path; drop the rows for tools you do not run. The composer check works by copying the phar to a .phar filename, because PHP’s phar:// stream wrapper refuses to open a file called plain composer.
Why composer’s bundled list mostly does not matter, and when it does
composer/ca-bundle tries the system store before its own copy. We checked the order it uses on this box:
composer/ca-bundle picks: /root/.ccr/ca-bundle.crt # SSL_CERT_FILE was set in the container
without SSL_CERT_FILE, composer/ca-bundle picks: /etc/ssl/certs/ca-certificates.crt
CaBundle::getSystemCaRootBundlePath() checks SSL_CERT_FILE, then openssl.cafile and curl.cainfo in php.ini, then a list of well-known distro paths, and only then falls back to the bundled cacert.pem. On a CloudPanel Ubuntu server with ca-certificates installed, the system store wins and the stale bundle is never read. It is read when composer runs in a slim Docker image with no ca-certificates package, or when openssl.cafile points at a file that fails composer’s validity check. Both happen in CI images more often than people think, so composer self-update to a build that ships ca-bundle 1.4.16 or 1.6.0 is still worth doing.

Two of the seven stores we counted still held an emSign root after the distro, pip and Node updates: composer’s bundle and Node 22.23.4.
The fix list for the VPS
This is what the audit turns into on our CloudPanel box, in the order we will run it.
apt update && apt install ca-certificatesand confirmdpkg-query -W ca-certificatesprints20260816ubuntu0.24.04.1(22.04:20260816ubuntu0.22.04.1). Thenupdate-ca-certificatesand restart PHP-FPM, because long-running FPM workers keep the old bundle mapped. Nginx and the WAF are irrelevant here: outbound trust is a client-side decision in the process making the call.php -i | grep -E 'curl.cainfo|openssl.cafile'on every PHP version CloudPanel runs. Any site that pins a custom bundle through.user.iniorphp_admin_valuekeeps the old roots until that file is replaced.composer self-update, then re-run the composer row of the script.- Node:
node -vfor every PM2 process, not just the shell default.pm2 jlist | grep -o '"node_version":"[^"]*"'lists what each process actually runs; anything below 22.23.4, 24.22.1 or 26.12.1 is restarted on a patched binary. Note from the table that even the patched 22.x still carriesemSign Root CA - G1; if you want Node to follow the system store instead of its snapshot, start it with--use-openssl-ca. - Go: rebuild any Go binaries with 1.26.10 or 1.27.3 so they carry the intermediate denylist, and restart them so they re-read
/etc/ssl/certs/ca-certificates.crt. Statically linked Go binaries built on another machine have no roots of their own, so they take the host’s store at run time, but the denylist only exists in the new toolchain. - Python tooling (
certbot, scripts underpip):pip install -U certifito 2026.10.18 or later,pip install -U pipto 26.2.2.
What we could not test here, and how to test it on your box
The live check is to hit the test hosts the researcher set up, dv-g3b-emudhra.we-have-a-problem.com (chains to the eMudhra root) and dv-g3b-aaa-emudhra.we-have-a-problem.com (chains through Sectigo’s AAA cross-sign), and expect every client to fail the handshake. Our workspace sits behind an inspecting proxy that both rewrites the certificate chain and refused the CONNECT to that host, so a handshake result from here would have described the proxy, not the trust stores. That is why this post audits the stores themselves and stops there. On the VPS the commands are:
for h in dv-g3b-emudhra.we-have-a-problem.com dv-g3b-aaa-emudhra.we-have-a-problem.com; do
echo "== $h"
curl -sS -o /dev/null -w "curl: %{http_code}\n" "https://$h/" || true
php -r '$c=curl_init("https://'$h'/"); curl_setopt($c,CURLOPT_RETURNTRANSFER,1); curl_exec($c); echo "php curl: ", curl_error($c) ?: "connected (BAD)", "\n";'
node -e 'fetch("https://'$h'/").then(()=>console.log("node: connected (BAD)")).catch(e=>console.log("node:",e.cause?.code||e.message))'
wp eval 'var_dump(is_wp_error(wp_remote_get("https://'$h'/")));' --path=/home/site/htdocs 2>/dev/null
done
Every line should fail. The second host is the one that tells Go versions apart: with the root gone, the eMudhra-root chain fails everywhere, but the AAA chain only fails on a client that either checks revocation or carries the 1.26.10 / 1.27.3 denylist.
What this changes in how we build servers
Two habits. First, every service that makes outbound HTTPS gets a line in the deploy notes saying which store it reads; the audit script is now part of the CloudPanel site template. Second, we stopped treating apt install ca-certificates as the end of a CA incident. The package fixes curl, PHP and Go on the same host, and nothing else.



