DEV Community

Cover image for A CA private key was the number 4. Your curl still trusts it (CVE-2026-111943)
Sam LABBE
Sam LABBE

Posted on

A CA private key was the number 4. Your curl still trusts it (CVE-2026-111943)

Explores why static binaries ignore system patches

The private key of a certificate authority — a chain of trust rooted in the Mozilla, Microsoft, Apple and Chrome root programs — is the number 4. Not a metaphor: the EC scalar is literally 4, on a P-384 curve where it should be a 384-bit random number. It has been recoverable from the public certificate since April 2025. I recovered it in 4 guesses and 2 milliseconds. And as of this morning, my Mac's curl still happily connects to servers impersonated with it.

The disclosure landed last week, the weaponization landed last night: mitmproxy now ships the actual compromised key behind a --emudhra flag, and CVE-2026-111943 got its number today. Browsers are safe. A very large amount of everything else is not — maybe including your terminal.

What happened

Eight eMudhra (emSign brand) intermediate CA certificates have incorrectly generated private keys — the top 376 bits of the P-384 scalars are all zeroes, leaving tiny integers you can brute-force from the certificate's public key alone. The intermediates chain to roots trusted by every major root program, and carry Sectigo cross-signs (through the old "AAA Certificate Services" root) as a second trust path. They were created in April 2025 with a validity stretching to 2040.

On Oct 17, a reporter known as Wayne reported it to the root programs with an undeniable proof: he signed the message "We have a problem here - Wayne" with each of the eight private keys.

  • Oct 17: Firefox and Chrome block the intermediates
  • Oct 19: Apple platforms block them; downstream trust stores notified
  • Oct 20: Microsoft platforms block them; the disclosure site goes public
  • Oct 21, 23:51 UTC: mitmproxy merges --emudhra — the compromised key, shipping in a pentest tool
  • Oct 22, morning: CVE-2026-111943 is assigned

The CA has revoked the certificates, and browsers push the blocks through their fast revocation channels (OneCRL, CRLite, CRLSets). But revocation only protects clients that consume revocation information. That is a much shorter list than you'd hope.

Why your browser is fine (the trust fabric held — again)

Two independent systems saved the browsers:

  1. Certificate Transparency: these intermediates have zero CT log entries for their entire 18-month lifetime. CT-enforcing clients — Firefox, Chrome, Edge, Safari — could not have been attacked, because a leaf cert chaining through an unlogged intermediate is rejected outright. The attack surface never existed for them.
  2. Fast revocation: OneCRL / CRLite / CRLSets block the intermediates directly.

We keep arriving at the same conclusion: the web PKI survives its worst incidents not because CAs don't fail, but because the ecosystem around them assumes they will. (Same lesson as Let's Encrypt's multi-perspective validation — witnesses, not promises.)

Who is not fine

Everything that validates certificates without CT and without consuming revocation:

  • curl, wget, git (HTTPS remotes), package managers, CI/CD runners
  • Language runtimes with bundled trust stores: Go 1.27.3 / 1.26.10 fixed it, Node (22.23.4 / 24.22.1 / 26.12.1), Python certifi 2026.10.19, Rust rustls-webpki 0.103.16, Java, Deno, Electron… Claude Code shipped a fix (2.1.303) — your AI tooling's bundled roots were in the blast radius too. Bun and Erlang certifi are still "pending".
  • macOS is its own special case: the built-in curl, git, python3, perl, ruby and openssl validate against /etc/ssl/cert.pem without checking revocation — and that file has not been updated.

I ran the disclosure's official self-check on my machine today:

$ curl https://dv-g3b-emudhra.we-have-a-problem.com/
Your client successfully connected to the site. That's bad!

$ curl https://dv-g3b-aaa-emudhra.we-have-a-problem.com/
Your client successfully connected to the site. That's bad!
Enter fullscreen mode Exit fullscreen mode

Both trust paths — emSign root and the Sectigo/AAA cross-sign — still validate on stock macOS. The second server's certificate is issued by EM DV TLS CA - G3B, one of the eight compromised intermediates. My terminal accepted it. A "certificate error" would have meant I'm safe.

And the exposure is not theoretical: the disclosure's checker servers count every client that still connects successfully, per user-agent and JA4 fingerprint. There is live traffic. There are also live results.

Run it yourself: recover a CA private key from its public certificate

You need nothing but a public file. The certificates are in the disclosure's proof.zip (and impacted-intermediates.json lists all eight). Then:

# recover.py — the private key from the public certificate alone
import sys
from cryptography import x509
from cryptography.hazmat.primitives.asymmetric.ec import derive_private_key

cert = x509.load_pem_x509_certificate(open(sys.argv[1], "rb").read())
pub = cert.public_key().public_numbers()

for d in range(1, 1_000_000):
    if derive_private_key(d, cert.public_key().curve).public_key().public_numbers() == pub:
        print(f"private key recovered: d = {d}")
        break
Enter fullscreen mode Exit fullscreen mode

Real run, on the EM OV TLS CA - G3C intermediate:

$ python3 recover.py EM-OV-TLS-CA-G3C.crt.pem
private key recovered: d = 4
Enter fullscreen mode Exit fullscreen mode

Four guesses. Two milliseconds. From 1,240 bytes of fully public data. A CA private key that should take longer than the age of the universe to brute-force fell to a loop that didn't even reach its first coffee. The verification is one command away, and Wayne's proof ships exactly that way:

openssl x509 -in EM-DV-TLS-CA-G3B.crt.pem -noout -pubkey | diff - EM-DV-TLS-CA-G3B.pub.pem
openssl dgst -sha384 -verify EM-DV-TLS-CA-G3B.pub.pem -signature EM-DV-TLS-CA-G3B.sig message.txt
Enter fullscreen mode Exit fullscreen mode

Then the weaponization, for the lab only: mitmproxy --emudhra makes the proxy sign interception certificates with the real compromised key, so any client that still trusts the chain — say, a stock macOS curl — accepts an impersonated site without a single warning. One flag. That's how undramatic this failure is to exploit.

The fix

  1. Patch the trust stores — the disclosure maintains the exact list. Headlines: Ubuntu/Debian/Alpine/Fedora/Arch ca-certificates (all shipped this week), Go 1.27.3 / 1.26.10, Node 22.23.4+, Python certifi 2026.10.19 (then pip install -U certifi), Rust rustls-webpki 0.103.16, Deno 2.9.8, Docker Desktop 4.96.2, Claude Code 2.1.303, curl CA bundle 2026-10-20.
  2. macOS, today, without waiting: strip the two affected roots from the file the built-in tools actually read (official workaround):
sudo cp -p /etc/ssl/cert.pem /etc/ssl/cert.pem.bak &&
sudo awk '/^(###|===) /{skip=0}
/^=== .*\/CN=(AAA Certificate Services|emSign ECC Root CA - G3)$/{skip=1}
!skip' /etc/ssl/cert.pem.bak | sudo tee /etc/ssl/cert.pem > /dev/null
Enter fullscreen mode Exit fullscreen mode

Then re-run the two curl self-checks. "Certificate error" is the sound of being safe.

  1. Linux servers: the disclosure ships sed-based workarounds for Debian/Ubuntu, Alpine, Fedora/RHEL-family, Arch, NixOS — they distrust emSign ECC Root CA - G3 and Comodo AAA Services root until the distro's ca-certificates update lands. The cross-sign path is the one people forget: blocking only the emSign root leaves the AAA path wide open — my second curl proved exactly that.
  2. Offline Windows machines: Microsoft ships the block through automatic trust-list updates; air-gapped fleets need the Block-EmudhraCompromisedCA.ps1 script (16 intermediates and cross-signs into the Disallowed store).

The nuance worth keeping

Your microcontroller probably escaped. Embedded devices don't ship 150-root bundles — they pin one root per connection, so the compromised chain simply isn't in their firmware. The flip side is scarier: those bundles are frozen until 2039. The next mis-generated CA whose root is pinned in firmware will have no such luck. The real exposure was never the constrained devices — it's the desktops, the CI runners, and everything written in Go.

TL;DR

A trusted CA shipped eight private keys that were guessable small integers, and they sat in the wild for 18 months. Browsers were saved by Certificate Transparency — again. Your terminal tools were not: check with the two curl one-liners, patch your trust stores, and on macOS apply the cert.pem workaround today. And if you want to feel it viscerally: recover the key yourself — it's four guesses.

Sources: official disclosure + self-checks + patched list · Mozilla bug tracker incident report · MCU analysis — Daniel Mangum · mitmproxy --emudhra commit · CVE-2026-111943

Top comments (6)

Collapse
 
ellis_38 profile image
Taylor Ellis •

We audited our CA bundle after that story and found two roots nobody on the team could name. The fix wasn't sexy: a quarterly check that every trusted root has an owner and a documented reason to be there. Still finds one or two mysteries each time.

Collapse
 
slabb profile image
Sam LABBE •

That's the audit landing — and "every root has an owner and a documented reason" is the known-writers registry applied to trust stores: the bundle's answer to who may vouch. Two upgrades when the quarterly check matures. First, seal the diff: this quarter's roots against last quarter's sealed list, so the mysteries become a named drift finding instead of a meeting anecdote — and the count becomes a trend, which is how you tell a healthy bundle from a quietly growing one. Second, cross-check against Certificate Transparency: the eMudhra intermediates had zero log entries for their entire 18-month lifetime, which is exactly why CT-enforcing browsers were immune while everything else wasn't — a root or intermediate absent from the logs is a finding on sight. The recurring mysteries are the system working; an audit that stops finding things has usually stopped looking.

Collapse
 
price_76 profile image
Sam Price •

The audit angle is the practical takeaway. How often do you re-check the bundle now?

Collapse
 
slabb profile image
Sam LABBE •

Two tiers. The bundle only changes when its shippers change it — distro ca-certificates updates, language stores like certifi, base image rebuilds — so the primary re-check rides those events: every time one lands, diff the bundle against the last sealed copy. The calendar sweep is the backstop for changes nobody announced, and quarterly is the right order of magnitude there — the eMudhra intermediates lived 18 months unnoticed; a quarter bounds any silent change to ninety days. What makes the cadence honest isn't the number, it's that the diff lands as an artifact: "we checked" becomes a record with a date and a result, not a memory. The drift law underneath: a check's cadence bounds the lie — event-triggered bounds it to zero, scheduled bounds it to the interval, and both beats either alone.

Collapse
 
_5c75b1d3a1b3628dec81_58 profile image
Yoshiyuku •

Great write-up, and the four-guess demo makes the point better than any advisory could.

One gap I’d flag is that patching ca-certificates and the runtimes only fixes software that reads the system or runtime store. A lot of software bakes its own CA bundle in at build time: static Go binaries, Electron apps, vendor agents, appliance firmware. Those keep trusting the affected roots until they’re rebuilt, and no package update will tell you which ones they are. Also, not every embedded device pins a single root. Some ship a full bundle that’s frozen at release.

With 10 years of reverse engineering behind me, I can sweep a set of binaries or firmware images for embedded certificate bundles and match them against the eight intermediates and their cross-signs by fingerprint. That gives you a list of what’s still exposed, rather than a guess. Happy to describe the method, or run it on one sample binary as a pilot.

Collapse
 
slabb profile image
Sam LABBE •

The gap is real and the post stopped one shelf short: the remediation covered package stores, the macOS workaround, and constrained firmware — and statically-linked builds carry their own trust store frozen at compile time, so "update ca-certificates" fixes none of the binaries that embedded the affected roots. The cleanest example is the Rust webpki-roots pattern: the bundle is a dependency, so it versions with the build, not with the OS — same for Electron bundles and appliance firmware, and no package update will ever tell you which builds are exposed.

Describe the method — in the open, like everything else in this series. My assumption: the simple version is nearly a script (scan the binary for DER/PEM blobs, extract, fingerprint-match issuer and SKI against the compromised set), and the hard part is everything that isn't that shape — packed firmware, custom formats, obfuscated stores. Write it up and the sweep becomes a tool anyone can run against their own fleet: the open-source answer to "which of our binaries still trust the broken root" is the script and the eight fingerprints, not a pilot.

🐏