DEV Community

Cover image for Eight CA private keys were integers under 256. I recovered all eight in 11 milliseconds.
frank chu
frank chu

Posted on

Eight CA private keys were integers under 256. I recovered all eight in 11 milliseconds.

Eight CA private keys were integers under 256. I recovered all eight in 11 milliseconds.

A P-384 private key is supposed to be a random integer with roughly 384 bits of entropy — a number so large that guessing it is not a thing physics allows. The private keys behind eight eMudhra intermediate CA certificates, disclosed today (October 20) at emudhra.we-have-a-problem.com, are integers less than 256.

Not "less than 2^256." Less than two hundred and fifty-six. The largest is 237. One of them is 4.

I didn't take anyone's word for that. The disclosure site publishes the eight certificates and, for each one, a signature over the message We have a problem here - Wayne, made by the reporter who recovered the keys and sent them to the root programs on October 17. So I downloaded proof.zip, verified the proof, and then recovered the keys myself.

Step 1: the proof checks out, 8 for 8

For each certificate: extract the public key, confirm it matches the published .pub.pem, and verify the signature with OpenSSL:

EM-DV-TLS-CA-G3B  pubkey MATCH   Verified OK
EM-DV-TLS-CA-G3C  pubkey MATCH   Verified OK
EM-EV-TLS-CA-G3B  pubkey MATCH   Verified OK
EM-EV-TLS-CA-G3C  pubkey MATCH   Verified OK
EM-OV-CS-CA-G3B   pubkey MATCH   Verified OK
EM-OV-CS-CA-G3C   pubkey MATCH   Verified OK
EM-OV-TLS-CA-G3B  pubkey MATCH   Verified OK
EM-OV-TLS-CA-G3C  pubkey MATCH   Verified OK
Enter fullscreen mode Exit fullscreen mode

8 for 8. Whoever signed that message holds all eight private keys. That part is not alleged; it's arithmetic.

Step 2: recovering the keys is a for-loop

An EC public key is Q = d·G. If d is a small integer, you don't attack the curve — you just walk up the multiples of G until one matches. This is the whole attack, in plain Python (standard library only):

def add(P, Q):  # point addition on P-384
    ...

R = None
for d in range(1, 512):
    R = add(R, (Gx, Gy))   # R = d*G, incrementally
    if R == (pubkey_x, pubkey_y):
        print("found", d)
        break
Enter fullscreen mode Exit fullscreen mode

Results, on this machine (a 2-vCPU Linux VM), searching from 1 upward:

EM-OV-TLS-CA-G3C   d=4     0.000s
EM-EV-TLS-CA-G3C   d=56    0.002s
EM-OV-CS-CA-G3B    d=66    0.003s
EM-EV-TLS-CA-G3B   d=115   0.005s
EM-OV-TLS-CA-G3B   d=155   0.007s
EM-DV-TLS-CA-G3B   d=156   0.007s
EM-DV-TLS-CA-G3C   d=230   0.011s
EM-OV-CS-CA-G3C    d=237   0.011s
Enter fullscreen mode Exit fullscreen mode

The set — {4, 56, 66, 115, 155, 156, 230, 237} — matches the values reported from Mozilla's incident record exactly. The slowest key took eleven milliseconds, and most of that is Python being Python. With any of these integers, anyone can mint a certificate for any domain that chains to a widely trusted root. Six of the eight intermediates are TLS (DV/OV/EV, in two generations, G3B and G3C); two are code-signing. Counting their Sectigo cross-signs, the disclosure's impacted-intermediates.json lists 16 entries, all now revoked with reason ca_compromise (eMudhra revoked its copies on October 18, Sectigo its cross-signs on October 19).

How does a CA end up with d=4?

According to eMudhra's account in Mozilla's incident bug, the key ceremony script had a flaw: during an approved deviation to test key wrap/unwrap, the script created a second key pair on the HSM — the weak one — with labels similar to the real keys. The weak keys are the ones that ended up in the CSRs, got signed by the emSign roots, and were sent to Sectigo for cross-signing. All eight certificates were created on April 30, 2025.

The part that should make you put your coffee down: there was a warning, sixteen months early. In May 2025, CRLs for the six TLS intermediates started failing signature checks — because they were being signed with the real keys, which don't match the certificates. Sectigo reported it. eMudhra re-signed the CRLs with the keys that matched the certificates — the weak ones — and closed the incident as a process error. The anomaly was the detection. It got explained away.

The browsers are fine. Everything else is the question.

The response, once reported, was fast: Chrome pushed a CRLSet block and Firefox completed its OneCRL block on October 17, Apple devices blocked on October 19, and today Mozilla removed the emSign ECC Root CA - G3 from NSS. The disclosure also notes there are no Certificate Transparency log entries for these intermediates, which means CT-enforcing browsers could not have been attacked with certs chained to them — a forged cert would have had to be logged to work in Chrome or Safari.

But curl, Python, Go, and most non-browser TLS clients do not enforce CT. For them, a forged certificate shown only to them leaves no trace anywhere. So the real exposure is every long-tail trust store that updates by package manager, on human timescales. The disclosure site publishes per-ecosystem patched versions (Ubuntu ca-certificates 20260816, Python certifi 2026.10.19, Go 1.27.3/1.26.10, and so on) — so I checked this machine, using the site's own test chains (chains.zip, twelve chains captured today: six via the emSign ECC root, six via AAA Certificate Services):

$ openssl verify -untrusted <chain>.pem <chain>.pem   # system store
dv-g3b (emSign ECC root)   OK        <- still trusted
dv-g3c (emSign ECC root)   OK        <- still trusted
ov-g3b (emSign ECC root)   OK        <- still trusted
ov-g3c (emSign ECC root)   OK        <- still trusted
ev-g3b (emSign ECC root)   OK        <- still trusted
ev-g3c (emSign ECC root)   OK        <- still trusted
all six AAA chains         verification failed
Enter fullscreen mode Exit fullscreen mode

Six of the twelve chains still verify against this VM's system store tonight. Live checks agree: curl returns 200 from the non-AAA test hosts, and Node's TLS stack accepts them too (the AAA hosts fail in both — the AAA root was already removed from the Mozilla store this chains through). The local package versions explain it: ca-certificates here is 20260601~24.04.1, not the patched 20260816ubuntu0.24.04.1, and the installed certifi is 2023.11.17 — nearly three years behind the patched 2026.10.19. This is a throwaway dev VM, but that's precisely the population at risk: machines nobody thinks of as "running a trust store," all of them running one.

Two honest limits on my test: openssl verify here was run without revocation checking (that's the site's own offline method, and it's the point — clients that don't check revocation are the exposed ones), and my curl goes through a proxy with its own CA bundle, which is why I treated the offline openssl verify against the system store as the primary result.

The lesson is not "CAs are careless"

The lesson is narrower and worse. The strongest part of this system — the cryptography — reduced to a for-loop because a ceremony script made a second key pair and a human picked the wrong label. The weakest part — a CRL that failed verification, in writing, sixteen months ago — was the one control that actually fired, and it was closed as a paperwork error. Every safeguard that worked this week (CRLSets, OneCRL, CT) is a browser safeguard. If your software verifies TLS outside a browser, your patch cadence is your revocation system, and tonight mine was three years stale.

Check your stores. The test takes one command, and the answer might be OK — which, this week, is the bad outcome.

Sources: the disclosure site and its proof/chains data, Mozilla incident bug 2083449 (eMudhra's account of the ceremony flaw and the 2025 CRL incident), and the HN discussion (563 points today). All verifications, key recoveries, and trust-store tests above were run locally today; the recovered d values match the set reported in the incident coverage.

Top comments (0)