Ocho claves de CA que cabían en un byte: el incidente de eMudhra y lo que destapó en el ecosistema Python
El 17 de octubre de 2026, a las 18:05 UTC, un investigador que firma como Wayne mandó un correo a Apple, Chrome, Microsoft, Mozilla y eMudhra. Decía que había recuperado la clave privada de ocho certificados intermedios de la CA emSign, y adjuntaba una prueba: un mensaje firmado con cada una de ellas. A las 21:48 UTC Mozilla había recuperado las mismas claves por su cuenta, sin que nadie le dijera cómo. Las ocho claves privadas, que según eMudhra se generaron en un HSM con certificación FIPS 140-2 nivel 3, eran números enteros menores que 256.
Yo las he recuperado también. El script tarda 0,2 segundos por certificado y cabe en veinte líneas. Lo enseño más abajo.
Lo que vino después es lo que me interesa contar aquí, porque afecta a cualquiera que ejecute Python: certifi publicó tres versiones en cinco días, cada una con un conjunto distinto de raíces emSign; pip, uv y CPython sacaron versiones de emergencia; y a 30 de octubre, en mi Mac con macOS 26.7.1, el curl, el git y el python3 que trae Apple siguen aceptando certificados firmados con esas claves. He comprobado cada afirmación de este artículo contra la fuente primaria, y donde no he podido, lo digo.
Qué he comprobado y qué no
Todo lo que hay aquí sale de estas fuentes, leídas entre el 29 de octubre y el 1 de noviembre de 2026:
- El bug 2083449 de Bugzilla, el informe de incidente de Mozilla, con más de 70 comentarios a 1 de noviembre, incluidas las respuestas de eMudhra. También los bugs 2083709 (retirada de las raíces C1 y C3 de NSS) y 2018979 (el plan de transición de las raíces de doble uso, de febrero).
- La página emudhra.we-have-a-problem.com, publicada por Dennis Jackson (Mozilla) con contribuciones de la comunidad. De ahí he bajado
proof.zip,chains.zip,impacted-intermediates.jsonyroots.json, y los he verificado con OpenSSL 3.6.5 y concryptography48.0.0. Los he tratado como datos, no como código: nada de lo que hay dentro se ha ejecutado. - Los wheels de certifi 2026.7.22, 2026.10.18, 2026.10.19 y 2026.10.23 descargados de PyPI, con sus fechas de subida, y el
cacert.pemextraído de cada uno. - El aviso GHSA-6q48-6c7w-9gw2, la issue #439 y los PR #437 y #440 de certifi, leídos por la API de GitHub.
- El hilo 109576 de discuss.python.org, abierto por Seth Larson el 20 de octubre, y la issue 160040 de CPython con el parche que bloquea las claves.
- El documento de soporte de Apple HT 149555.
Lo que no he podido verificar va atribuido a quien lo dice: la causa raíz exacta del fallo de generación (eMudhra sigue respondiendo preguntas en el bug), si alguien explotó las claves durante los 18 meses que estuvieron vivas, y la afirmación de que macOS 27.2 deshizo los arreglos manuales (no tengo esa versión).
Las piezas
Ocho certificados intermedios, emitidos el 30 de abril de 2025 en la misma ceremonia de claves. Seis son de TLS (DV, OV y EV, en dos series, B para eMudhra BV en Países Bajos y C para eMudhra Inc en Estados Unidos) y dos de firma de código. Todos con clave EC P-384.
Cada uno de los ocho existe dos veces: un certificado emitido por una raíz de eMudhra y otro, con la misma clave pública, emitido por Sectigo bajo AAA Certificate Services. Dieciséis certificados, ocho claves.
| Intermedio | Emisor directo | SHA-256 del certificado eMudhra |
|---|---|---|
| EM DV TLS CA - G3B | emSign Root TLS CA - G3 | 42C4ACCD…BA1493 |
| EM OV TLS CA - G3B | emSign Root TLS CA - G3 | 98B89662…3BC25B |
| EM EV TLS CA - G3B | emSign Root TLS CA - G3 | E7568FE6…333838 |
| EM DV TLS CA - G3C | emSign Root TLS CA - G3 | 0164D7CE…934D19 |
| EM OV TLS CA - G3C | emSign Root TLS CA - G3 | CB6E1516…9C7C3F |
| EM EV TLS CA - G3C | emSign Root TLS CA - G3 | 432EC25F…0261F3 |
| EM OV CS CA - G3B | emSign Root CS CA - G3 | 9E39DE28…B067B4 |
| EM OV CS CA - G3C | emSign Root CS CA - G3 | 4B2460A4…C630B1 |
Los hashes completos están en el impacted-intermediates.json de la página del incidente y en el comentario 0 del bug de Mozilla; coinciden.
Y las raíces, que es donde se decide todo lo que viene después:
| Raíz | Papel | En certifi 2026.7.22 | En Mozilla |
|---|---|---|---|
| emSign Root TLS CA - G3 | emisor directo de los seis TLS | no | no (solo Microsoft) |
| emSign ECC Root CA - G3 | firma cruzada a la anterior | sí | sí, hasta NSS 3.131.1 |
| AAA Certificate Services (Sectigo) | emisor de las firmas cruzadas | no | retirada hace años |
| emSign Root CA - G1 | ninguno en este incidente | sí | sí |
| emSign Root CA - C1 | ninguno en este incidente | sí | retirada en NSS 3.131.1 |
| emSign ECC Root CA - C3 | ninguno en este incidente | sí | retirada en NSS 3.131.1 |
Fíjate en la primera fila. La raíz que emitió los intermedios comprometidos, emSign Root TLS CA - G3, no está en el programa de Mozilla ni en certifi. Llega a tu bundle porque emSign ECC Root CA - G3 (huella 86A1ECBA…6BBE6B), que sí está, le firmó un certificado cruzado. Esa firma cruzada es la única puerta por la que un certificado emitido por EM DV TLS CA - G3B valida contra certifi. Por eso certifi quita la ECC G3 y deja la G1: la G1 es una raíz RSA distinta de esta jerarquía. Distinta no es lo mismo que ajena: los intermedios RSA G2B y G2C se generaron el mismo 30 de abril de 2025 en el mismo modelo de HSM y Sectigo los firmó en cruzada el 14 de mayo (comentarios 58 y 66, sin respuesta). eMudhra dice que siguieron «otro camino» y no están afectados (comentario 11), pero no lo ha documentado.
La vía de Sectigo (AAA Certificate Services) no afecta a certifi porque Mozilla retiró esa raíz hace tiempo y certifi va detrás de Mozilla. Sí afecta a los almacenes que la conservan: el de Microsoft, el de Apple y el /etc/ssl/cert.pem de macOS. Lo he comprobado en el mío: ahí está.
El fallo: una clave privada de un byte
Una clave privada ECDSA sobre P-384 es un entero d elegido al azar entre 1 y el orden del grupo, un número de 384 bits. La pública es d·G, el punto generador multiplicado d veces. La seguridad descansa en que, dado d·G, nadie puede recuperar d. Si d es menor que 256, cualquiera puede: pruebas los 255 valores.
Dennis Jackson lo describe en el comentario 5 del bug: “Las 8 claves afectadas usaron todas enteros menores que 256. Es decir, las claves privadas son, en la práctica, todo ceros salvo el último byte.” Yo lo he reproducido con las claves públicas de los ocho certificados que vienen en proof.zip:
import sys, time
from cryptography import x509
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import serialization
curve = ec.SECP384R1()
for path in sys.argv[1:]:
cert = x509.load_pem_x509_certificate(open(path, "rb").read())
pub = cert.public_key().public_bytes(
serialization.Encoding.DER,
serialization.PublicFormat.SubjectPublicKeyInfo)
t = time.time()
for d in range(1, 2**16):
k = ec.derive_private_key(d, curve)
if k.public_key().public_bytes(
serialization.Encoding.DER,
serialization.PublicFormat.SubjectPublicKeyInfo) == pub:
break
cn = cert.subject.get_attributes_for_oid(x509.NameOID.COMMON_NAME)[0].value
print(f"{cn:22s} d = {d} ({time.time()-t:.2f}s)")
EM DV TLS CA - G3B d = 156 (0.18s)
EM DV TLS CA - G3C d = 230 (0.27s)
EM EV TLS CA - G3B d = 115 (0.13s)
EM EV TLS CA - G3C d = 56 (0.06s)
EM OV CS CA - G3B d = 66 (0.08s)
EM OV CS CA - G3C d = 237 (0.27s)
EM OV TLS CA - G3B d = 155 (0.18s)
EM OV TLS CA - G3C d = 4 (0.00s)
El d = 4 del intermedio OV G3C es el que Christian Heimes cita en el hilo de Python (“Una clave elíptica con valor de clave privada d=4 no es segura”). Publico los ocho valores porque los certificados llevan revocados desde el 18 de octubre, las claves se recuperan en menos de un segundo con el script de arriba y varias personas ya las habían publicado antes que yo. Ocultarlas no protegería a nadie.
También he verificado la prueba original de Wayne. El mensaje de proof.zip dice “We have a problem here - Wayne”, y la firma de cada intermedio valida contra la clave pública de su certificado:
openssl pkeyutl -verify -certin -inkey EM-DV-TLS-CA-G3B.crt.pem \
-rawin -digest sha384 -in message.txt -sigfile EM-DV-TLS-CA-G3B.sig
# Signature Verified Successfully
Lo que dice eMudhra de la causa
Esto ya no lo puedo verificar; es la versión de la CA, en los comentarios 10 y 11 del bug. Las claves se generaron en HSM Utimaco (los otros intermedios de la casa, en Entrust y Crypto4A, no presentan el problema). Ese día se aprobó una desviación del procedimiento habitual para probar un envoltorio y desenvoltorio de claves (wrap/unwrap), porque estas claves iban a exportarse a los centros de datos de Estados Unidos y Europa. Según eMudhra, “un fallo en el código” del script de la ceremonia creó un segundo juego de claves, más débiles, y por tener etiquetas parecidas fueron esas, y no las originales, las que se usaron para generar los CSR que firmó la raíz y que se enviaron a Sectigo para la firma cruzada.
Eso explica una cosa rara de 2025: el bug 1970728, en el que las CRL de estos mismos seis intermedios TLS tenían firmas inválidas. Las habían firmado con las claves originales, que no se correspondían con los certificados, y las volvieron a firmar con las débiles. Nadie se preguntó entonces por qué había dos juegos de claves.
Lo que sigue abierto en el bug, a 30 de octubre: qué hizo exactamente el script, si el HSM estaba en modo FIPS, si los intermedios RSA (series G2B y G2C) generados el mismo día en el mismo modelo de HSM pasaron por el mismo paso, y por qué el auditor WebTrust no lo cazó. Hay varias personas pidiendo el script y los registros de la ceremonia. Yo no afirmo nada más allá de lo que eMudhra ha escrito.
Lo que no se sabe
Si alguien usó estas claves antes de que se revelaran. Los intermedios existen desde abril de 2025; la página del incidente dice que no hay entradas en los registros de Certificate Transparency para ellos en ese periodo, lo que apunta a que los clientes que exigen CT (Firefox, Chrome, Edge, Safari) no fueron atacados en ese tiempo. Los que no exigen CT, que son casi todos los clientes que no son navegadores, incluido el ssl de Python, no dejan rastro. eMudhra dice que solo EM DV TLS CA - G3C llegó a estar en producción y que no emitió certificados de entidad final. No hay forma pública de confirmar ni desmentir que hubo explotación antes del 17 de octubre, y cualquiera que te diga lo contrario está adivinando.
Después de la revelación sí hay datos. Según el comentario 44 del bug (25 de octubre), entre las 09:40 UTC del 20 de octubre y las 22:02 del 21 se registraron en logs de CT 107.107 certificados firmados con seis de las ocho claves, y todas las firmas verifican. 106.050 son pruebas de concepto con google.com como único SAN; otro, emsign.filippo.io, lleva SCT de logs que Chrome da por utilizables. Casi ninguno apunta a CRL ni a OCSP de eMudhra, así que un cliente que solo comprueba la revocación de la hoja no tiene nada que comprobar. La exposición, por tanto, no se limita al software que nunca comprueba revocación: solo paran estos certificados los clientes que revisan toda la cadena, reciben una lista de bloqueo empujada o han quitado la raíz.
Cronología
Todo en UTC. Las horas de PyPI son las del campo upload_time del JSON del índice.
| Cuándo | Qué |
|---|---|
| 2025-04-30 | Ceremonia de claves. Se emiten los ocho intermedios. |
| 2026-10-17 18:05 | Wayne envía el informe a los cuatro programas de raíz y a eMudhra. |
| 2026-10-17 20:06 | Mozilla recibe el informe, reenviado por el programa de raíz de Chrome (comentario 0). eMudhra lo acusa a las 21:29. |
| 2026-10-17 21:48 | Mozilla recupera las claves por su cuenta. A las 22:59 están en OneCRL; Chrome las tenía en el CRLSet 10843 a más tardar a las 22:46. |
| 2026-10-18 09:24 a 09:38 | eMudhra revoca los ocho con motivo cACompromise. |
| 2026-10-18 16:19 | Alex Gaynor fusiona el PR #437 de certifi, “Remove emsign”: fuera las cuatro raíces emSign. A las 16:21 está en PyPI como 2026.10.18. |
| 2026-10-19 12:43 | eMudhra pide a Mozilla la retirada total de C1 y C3 (bug 2083709). Es la continuación del plan de doble uso de febrero, no del incidente. |
| 2026-10-19 14:31 | Se abre la issue #439 de certifi, “Why was emSign removed?”. Universidades de Estados Unidos (InCommon emite bajo emSign) reportan roturas. |
| 2026-10-19 14:55 | Sectigo da por completada la revocación de las ocho firmas cruzadas bajo AAA (comentario 13; la había anunciado en el 6). |
| 2026-10-19 | Apple empieza a bloquear los doce certificados en sus frameworks (según su documento de soporte). |
| 2026-10-20 02:10 | Se fusiona el PR #440, “Restore unaffected emSign roots”: vuelven G1, C1 y C3. A las 02:24 es certifi 2026.10.19. |
| 2026-10-20 13:06 | Mozilla anuncia la retirada de emergencia de emSign ECC Root CA - G3 (comentario 18). Ese mismo día entran en NSS los commits que quitan C1 y C3. |
| 2026-10-20 16:57 | Seth Larson abre el hilo en discuss.python.org. Sale pip 26.2.2, que solo cambia certifi a 2026.10.19. |
| 2026-10-20 19:35 | GitHub publica GHSA-6q48-6c7w-9gw2, crítico, sin CVE. |
| 2026-10-20 | uv 0.13.4 quita la raíz de su bundle. |
| 2026-10-21 | Christian Heimes abre la issue 160040 de CPython. Salen Python 3.15.1, 3.14.9 y 3.13.17 con pip 26.2.2 y certifi 2026.10.19. uv 0.13.5 actualiza sus CPython gestionados a esos. |
| 2026-10-23 | Apple bloquea también la raíz ECC G3 en sus frameworks. certifi 2026.10.23 (11:36) quita C1 y C3 siguiendo a NSS 3.131.1, que retira G3, C1 y C3 a la vez. |
| 2026-10-24 | Se fusiona el bloqueo por SPKI en CPython. CVE-2026-115557. |
| 2026-10-26 | Python 3.15.2, 3.14.10, 3.13.18, 3.12.16 y 3.11.18 con ese bloqueo. uv 0.13.6 los incorpora. |
| 2026-10-28 | La página del incidente publica que git, curl y python3 de Apple siguen confiando en los intermedios. |
Las cuatro versiones de certifi
Aquí es donde más desinformación he visto, así que lo he mirado wheel a wheel:
| Versión | Subida a PyPI | Certificados | Raíces emSign dentro | Rechaza los intermedios |
|---|---|---|---|---|
| 2026.7.22 | 2026-07-22 | 121 | G1, ECC G3, C1, ECC C3 | no |
| 2026.10.18 | 2026-10-18 16:21 | 117 | ninguna | sí |
| 2026.10.19 | 2026-10-20 02:24 | 120 | G1, C1, ECC C3 | sí |
| 2026.10.23 | 2026-10-23 11:36 | 118 | G1 | sí |
Dos matices que importan:
El aviso GHSA marca como vulnerable >= 2019.03.09, < 2026.10.18 y como parcheada la 2026.10.18. Solo esa tabla de versiones deja la 10.18 como la parcheada; el texto del aviso (actualizado el 26 de octubre) dice que la 2026.10.19 restauró las raíces emSign no afectadas y recomienda actualizar a la 10.19 o posterior. La 10.23 no la menciona. Las tres rechazan la cadena, así que es correcto en lo técnico, pero incompleto en lo práctico: la 10.18 rompe cualquier conexión con un servicio bajo emSign Root CA - G1, que es donde eMudhra está migrando a sus clientes, y la 10.19 trae dos raíces que Mozilla ya ha retirado. La que corresponde al estado de Mozilla a 30 de octubre es la 2026.10.23. Si tu escáner de dependencias te dice que con 10.18 estás bien, te dice la verdad a medias.
He leído en varios sitios que “la 2026.10.19 restauró G1 y G3”. Es falso. Restauró G1, C1 y C3. La ECC G3 no ha vuelto y no va a volver, y es justo la que cuelga de los certificados de InCommon (ver la conclusión).
Por qué certifi solo podía quitar la raíz
Es la pregunta que se hace la issue #439 y la respuesta de Gaynor es directa: “no existe ninguna facilidad para revocar solo un intermedio. Dada la alternativa de exponer a todos los usuarios de certifi a ataques MITM, no había mejor opción.”
certifi es un fichero. Un cacert.pem con 118 certificados raíz y una función where() que te devuelve su ruta. No tiene lista de bloqueo, no tiene revocación, no sabe nada de intermedios. Cuando OpenSSL valida una cadena con ese fichero como ancla, lo único que puede hacer certifi es decidir qué anclas hay. Si una raíz legítima ha firmado un intermedio con una clave rota, la única palanca es quitar la raíz, y con ella a todos los que cuelgan de ella sin tener culpa.
Firefox tiene OneCRL, una lista de intermedios revocados que se empuja a los clientes sin actualizar el navegador; las claves de eMudhra estaban dentro cinco horas después del informe. Chrome tiene CRLSets y bloqueó las seis TLS por SPKI esa misma noche. Apple empuja “datos de configuración de certificados” a sus dispositivos sin actualización de sistema. Python no tiene nada de eso. Tampoco consulta CRL ni OCSP por defecto, ni exige Certificate Transparency. Durante los tres días que pasaron entre el informe y la primera versión de pip con el arreglo, cualquier requests.get() del mundo aceptaba un certificado emitido con d = 4.
Y la raíz del problema no es certifi: es que un bundle de raíces estático se usa como si fuera un programa de raíz completo. Los programas de raíz (Mozilla, Chrome, Apple, Microsoft) son raíces más revocación más políticas más telemetría. certifi es solo la primera pata, congelada en la fecha de la release, copiada dentro de pip, dentro de requests, dentro de cada imagen de Docker y de cada venv. Heimes añade un dato en la issue de CPython: en sus escaneos ha encontrado paquetes con versiones recientes que llevan un bundle de hacia 2015/2016, sin ISRG Root X1, la raíz de Let’s Encrypt. Esos bundles tampoco van a quitar la ECC G3.
Reproducirlo
chains.zip trae la cadena completa que sirve cada host de prueba, en PEM y con la hoja primero. La verificación offline contra cada bundle de certifi es una línea:
for v in 2026.7.22 2026.10.18 2026.10.19 2026.10.23; do
printf "%-12s " $v
openssl verify -CAfile certifi-$v.pem \
-untrusted dv-g3b-emudhra.we-have-a-problem.com.pem \
dv-g3b-emudhra.we-have-a-problem.com.pem 2>&1 | tail -1
done
# 2026.7.22 dv-g3b-emudhra.we-have-a-problem.com.pem: OK
# 2026.10.18 error ... verification failed
# 2026.10.19 error ... verification failed
# 2026.10.23 error ... verification failed
He pasado las cuatro cadenas interesantes por los cuatro bundles: la directa, la de Sectigo (-aaa-), la que no está en CT (-noct-) y la de rollover (un certificado de cambio de clave autoemitido por el intermedio comprometido, que algunos bloqueos por emisor no cazan; la explicación está en el README.txt de chains.zip y apunta a la sección 6.1.4 (l) del RFC 5280). Resultado: con 2026.7.22 pasan las tres que van por la ECC G3, incluida la de rollover; la de Sectigo falla porque certifi no tiene AAA. Con cualquiera de las tres versiones de octubre fallan las cuatro.
Lo mismo en vivo, con el módulo ssl de Python contra el host que sirve una hoja sin registrar en CT:
import ssl, socket, sys
host = "dv-g3b-noct-emudhra.we-have-a-problem.com"
for cafile in sys.argv[1:]:
ctx = ssl.create_default_context(cafile=cafile)
try:
with socket.create_connection((host, 443), timeout=20) as s, \
ctx.wrap_socket(s, server_hostname=host) as t:
print(f"{cafile:18s} -> handshake OK, {t.version()}")
except ssl.SSLCertVerificationError as e:
print(f"{cafile:18s} -> rechazado: {e.verify_message}")
2026.7.22.pem -> handshake OK, TLSv1.3
2026.10.18.pem -> rechazado: unable to get local issuer certificate
2026.10.19.pem -> rechazado: unable to get local issuer certificate
2026.10.23.pem -> rechazado: unable to get local issuer certificate
Ese “handshake OK” lo ha dado mi Python 3.10.11 de python.org, que ya no recibe parches. Volveré sobre él.
El ecosistema Python, pieza a pieza
pip. Trae su propia copia de certifi en pip/_vendor/certifi/ y la carga siempre. Desde la 24.2 usa además truststore para consultar el almacén del sistema, pero solo con Python 3.10 o posterior (el python3 3.9 de Apple usa solo certifi) y en modo aditivo: certifi más sistema, y no hay forma soportada de quitar certifi. Heimes lo dice sin rodeos en el hilo: “cualquier pip < 26.2.2 seguirá confiando en las CA intermedias comprometidas con independencia del almacén de confianza del sistema, así que desconfiar de ellas a nivel de SO no basta.” Y: “Actualizar pip es el único arreglo fiable.” REQUESTS_CA_BUNDLE, PIP_CERT y --cert añaden anclas, no las quitan. La 26.2.2 (20 de octubre) solo hace una cosa: subir certifi a 2026.10.19. Hay una issue abierta para pasar a truststore solo.
uv. No usa certifi; lleva las raíces de Mozilla en webpki-root-certs. La 0.13.4 subió a webpki-root-certs 1.0.10, que quita la ECC G3, y publicó su propio aviso (GHSA-hq85-5cpj-mr62). La 0.13.5 fue más allá: rustls-webpki 0.103.16 rechaza cualquier cadena que contenga una de las claves comprometidas, esté la raíz o no. Y el 21 de octubre actualizó sus CPython gestionados a los que llevan pip 26.2.2. Los 3.11.18 y 3.12.16 con el bloqueo por SPKI llegan en la 0.13.6 (26 de octubre). Para arrancar de forma segura desde un uv viejo, William Woodruff propone UV_SYSTEM_CERTS=true uv self update, que verifica con el almacén del sistema en lugar del incrustado. Funciona si tu sistema está al día; en macOS y Windows lo estaba.
requests. Usa certifi.where() como bundle por defecto. Tu versión de requests da igual; la de certifi, no. Con REQUESTS_CA_BUNDLE apuntando a otro fichero, usa ese y solo ese.
urllib3. Es la excepción: desde la 2.0 no depende de certifi. Si no le pasas ca_certs, llama a load_default_certs() del contexto SSL, que carga el almacén del sistema (lo he mirado en util/ssl_.py). Un urllib3 a pelo en un Linux con ca-certificates actualizado estaba protegido antes que requests en la misma máquina.
CPython. Dos movimientos distintos. El 21 de octubre salieron 3.15.1, 3.14.9 y 3.13.17 con pip 26.2.2 y certifi 2026.10.19 en el instalador de macOS y en ensurepip. Para 3.11 y 3.12 no: Thomas Wouters explicó que no actualizan pip en versiones de solo seguridad porque pip no tiene ramas de mantenimiento y un pip nuevo puede romper instalaciones. Ahí 3.11 sigue con pip 24.0 y 3.12 con 25.0.1 en ensurepip; cada python -m venv que hagas nace vulnerable hasta que actualices pip dentro.
El segundo movimiento es el bueno. La issue 160040 de Heimes propone lo que Go había hecho dos días antes para la misma CA (golang/go#82345): una lista de bloqueo de claves públicas dentro del propio módulo ssl. El parche añade un callback de verificación en Modules/_ssl.c que, por cada certificado de la cadena, calcula el SHA-256 de su SubjectPublicKeyInfo en DER y lo compara con una tabla de ocho entradas en Modules/_ssl/distrusted_spki.h. Si coincide, X509_V_ERR_CERT_REJECTED. Se compara la clave, no el certificado, así que cubre también las firmas cruzadas de Sectigo y los rollovers, y se aplica a todo SSLContext con verificación activada sin que la aplicación haga nada. Está en 3.15.2, 3.14.10, 3.13.18, 3.12.16 y 3.11.18, publicadas el 26 de octubre, como CVE-2026-115557. El comentario de cabecera del fichero la llama “una medida extraordinaria y última línea de defensa”.
Con uno de esos Pythons, el script de arriba habría dado “rechazado” también con certifi 2026.7.22. Eso es lo que significa defensa en profundidad: que el bundle viejo olvidado en una imagen de hace un año deje de ser suficiente para que te la cuelen.
Las imágenes de Docker. python:3.15-slim-trixie llevaba pip 26.2.1 el 21 de octubre, y las de 3.11 y 3.12 van a seguir con pip viejo salvo que quien construya encima lo actualice. Las que ya llevan CPython 3.11.18 o 3.12.16 sí traen el bloqueo por SPKI en ssl; lo que sigue viejo es su pip. Debian es un caso aparte: parchea su python3-pip para usar el almacén del sistema, así que su ca-certificates del 20 de octubre lo arregló de paso.
macOS, el caso raro
Apple lo dice en su propio documento de soporte, con más claridad de la habitual: desde el 19 de octubre bloquea los doce certificados en Safari, Mail y cualquier app que use sus frameworks de red, y desde el 23 también la raíz ECC G3. Eso llega por datos de configuración, sin actualizar el sistema. Pero “algunas herramientas incluidas con macOS o con las Xcode Command Line Tools — como curl y git — comprueban los certificados contra /etc/ssl/cert.pem y todavía pueden aceptar estos certificados hasta una futura actualización de software”.
Lo he comprobado en mi máquina, macOS 26.7.1, el 30 de octubre:
$ openssl crl2pkcs7 -nocrl -certfile /etc/ssl/cert.pem \
| openssl pkcs7 -print_certs -noout \
| grep -E 'emSign|AAA Certificate Services'
subject=... CN=AAA Certificate Services
subject=... CN=emSign ECC Root CA - C3
subject=... CN=emSign Root CA - C1
subject=... CN=emSign ECC Root CA - G3
subject=... CN=emSign Root CA - G1
$ /usr/bin/curl -sS -o /dev/null -w '%{http_code}\n' \
https://dv-g3b-noct-emudhra.we-have-a-problem.com/
200
$ /usr/bin/curl -sS -o /dev/null -w '%{http_code}\n' \
https://dv-g3b-aaa-noct-emudhra.we-have-a-problem.com/
200
$ /usr/bin/python3 -c 'import urllib.request as u; \
print(u.urlopen("https://dv-g3b-noct-emudhra.we-have-a-problem.com/").status)'
200
$ /usr/bin/git ls-remote https://git-emudhra.we-have-a-problem.com/check.git
2975b9930644b321e18ecae09e29c15b955e6103 HEAD
Las dos vías, la de eMudhra y la de Sectigo, abiertas. El python3 de Apple es un 3.9.6 enlazado con LibreSSL 2.8.3 que lee /etc/ssl/cert.pem; el git es un 2.54.0 que usa libcurl con SecureTransport pero verifica contra el mismo fichero. Un atacante en la red entre tú y GitHub puede servirte otro repositorio, y la página del incidente tiene una demo de eso: un git clone que resuelve github.com a su servidor de pruebas y devuelve un README que empieza por “INTERCEPTED”. No la he ejecutado; no me hace falta después de ver el ls-remote.
El arreglo manual que proponen (y que Apple presenta como “pasos opcionales”) es quitar emSign ECC Root CA - G3 de /etc/ssl/cert.pem con un awk, y si quieres cerrar también la vía de Sectigo, quitar AAA Certificate Services, con el riesgo de romper sitios legítimos que aún cuelgan de ella. Apple advierte de que una actualización puede devolver el fichero a su estado original. La página del incidente afirma que macOS 27.2 lo hizo el 26 de octubre; yo no tengo 27.2 y no puedo confirmarlo.
Hay un segundo frente en macOS que no es de Apple: el instalador de python.org. Trae certifi dentro y un Install Certificates.command que lo instala en el site-packages del framework. Mi 3.10.11 tenía certifi 2026.04.22 y un pip 23.0.1 con certifi 2022.12.07 vendorizado. Ese Python no va a recibir el bloqueo por SPKI porque está fuera de soporte desde hace un año. La única salida ahí es actualizar certifi y pip a mano, o dejar de usarlo.
Auditar qué bundle usa tu proceso
No es una pregunta con una sola respuesta. En la misma máquina conviven el almacén del sistema, el certifi del intérprete, el certifi de cada venv, el de cada pip y los que traen herramientas con su propio runtime (uv, conda, pixi, Electron). Estas son las preguntas que yo hago, en orden:
# Qué certifi ve este intérprete y de dónde lo saca
python -c 'import certifi; print(certifi.__version__, certifi.where())'
# Qué usaría el módulo ssl sin certifi (cafile=None en macOS = nada, salvo
# que algo llame a load_default_certs)
python -c 'import ssl; print(ssl.get_default_verify_paths())'
# Qué certifi lleva pip DENTRO, que no es el de arriba
python -m pip debug 2>/dev/null | grep -i certifi
# Qué usa requests
python -c 'import requests; print(requests.certs.where())'
# Variables que desvían el bundle sin que lo veas en el código
env | grep -E 'SSL_CERT_FILE|SSL_CERT_DIR|REQUESTS_CA_BUNDLE|CURL_CA_BUNDLE|PIP_CERT|UV_SYSTEM_CERTS'
Y la comprobación que no miente: la huella de las raíces que hay dentro del fichero, sea cual sea su versión declarada. Este script falla si el bundle contiene la raíz ECC G3, la de los intermedios comprometidos, y avisa (sin fallar) si contiene C1 o C3, que no están afectadas pero que Mozilla ha retirado después; solo necesita la biblioteca estándar y vale para CI. Mira el bundle de certifi o de Mozilla, no los almacenes del sistema: no detecta la vía de Sectigo (AAA Certificate Services), que certifi no lleva pero macOS sí:
#!/usr/bin/env python3
"""Fail if a CA bundle contains the root with the compromised intermediates; warn on roots Mozilla retired later."""
import hashlib, re, ssl, sys
# Root whose intermediates have compromised keys: the bundle is exploitable.
AFFECTED = {
"86A1ECBA089C4A8D3BBE2734C612BA341D813E043CF9E8A862CD5C57A36BBE6B": "emSign ECC Root CA - G3",
}
# Not affected by the incident (certifi 2026.10.19 restored them), but retired by Mozilla in NSS 3.131.1.
RETIRED = {
"125609AA301DA0A249B97A8239CB6A34216F44DCAC9F3954B14292F2E8C8608F": "emSign Root CA - C1",
"BC4D809B15189D78DB3E1D8CF4F9726A795DA1643CA5F1358E1DDB0EDC0D7EB3": "emSign ECC Root CA - C3",
}
PEM = re.compile(r"-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----", re.S)
def main(paths):
bad = 0
for path in paths:
for block in PEM.findall(open(path, encoding="ascii", errors="ignore").read()):
fp = hashlib.sha256(ssl.PEM_cert_to_DER_cert(block)).hexdigest().upper()
if fp in AFFECTED:
print(f"FAIL {path}: contains {AFFECTED[fp]} ({fp[:16]}...)")
bad += 1
elif fp in RETIRED:
print(f"WARN {path}: contains {RETIRED[fp]}, retired by Mozilla but not affected ({fp[:16]}...)")
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:] or [__import__("certifi").where()]))
Sin argumentos mira el certifi del intérprete que lo ejecuta. En CI yo lo paso también por el de pip:
python check_bundle.py \
"$(python -c 'import certifi; print(certifi.where())')" \
"$(python -c 'import pip._vendor.certifi as c; print(c.where())')"
Contra los cuatro wheels da lo que esperas: 2026.7.22 falla (ECC G3) y avisa de C1 y C3, 2026.10.18 y 2026.10.23 pasan limpias, y 2026.10.19 pasa con dos avisos (C1 y C3). Lo mismo con el bundle de pip: el de pip 26.2.2 (certifi 2026.10.19) pasa con los dos avisos y el de pip 26.2.1 falla. Las huellas salen del bug 2083709 y de roots.json, y las he recalculado yo desde el PEM de 2026.7.22. Cuando Mozilla retire otra raíz, añades una línea a la lista que toque.
Qué cambiar
Lo urgente, por orden de daño:
- pip 26.2.2 o posterior en cada entorno, no solo en el sistema. La 26.2.2 exige Python 3.10 o posterior (su metadato
Requires-Python): con 3.9 o anterior, como elpython3de Apple, no se puede instalar; ahí lo que hay que hacer es cambiar de intérprete, y uv avisa en sus notas de que 3.9 y 3.10 siguen con pip y bundles que confían en la raíz. Un venv tiene su propio pip con su propio certifi. El script de Ricardo Newbery en el hilo busca lospyvenv.cfgde tu home y actualiza pip en cada uno desde un wheel con hash verificado. Para no depender de un pip vulnerable para descargar el pip nuevo, instálalo por URL directa con susha256, que es lo que recomienda Larson en el mensaje 5 del hilo. - certifi 2026.10.23 o posterior, y en el lockfile. Si estás en 10.18 no estás mal, pero te vas a encontrar roturas con la G1; si estás en 10.19 llevas dos raíces que ya no existen en Mozilla.
- Reconstruir las imágenes de contenedores sin caché de capas. El
pip installde hace tres semanas dejó un certifi de hace tres semanas, y elFROM python:3.12del 30 de octubre trae un pip que no se ha actualizado (susslsí bloquea las claves si la imagen lleva 3.12.16 o posterior). - CPython 3.15.2, 3.14.10, 3.13.18, 3.12.16 o 3.11.18. Es la única pieza que te protege aunque te hayas dejado un bundle viejo en algún sitio.
- uv 0.13.6 o posterior (la que pide Larson): la 0.13.5 ya bloquea por clave, no por raíz, pero los CPython 3.11 y 3.12 gestionados con el bloqueo llegan en la 0.13.6.
- En macOS, si usas el
gito elcurldel sistema para algo que importe, aplica el arreglo de/etc/ssl/cert.pemy vuelve a comprobarlo tras cada actualización. Si tus runners de CI son Macs, esto va primero.
Lo estructural, para que la próxima vez duela menos:
Deja que el sistema operativo verifique. truststore usa la API de verificación nativa (Security.framework en macOS, CryptoAPI en Windows, OpenSSL con el almacén del sistema en Linux), que trae revocación y las listas de bloqueo del fabricante. En tu código, antes de importar nada que abra conexiones:
import truststore
truststore.inject_into_ssl()
import requests # a partir de aquí usa el almacén del sistema
Con pip ya viene puesto desde la 24.2, pero recuerda el matiz: aditivo. En uv, UV_SYSTEM_CERTS=true o --native-tls. La contrapartida es que heredas lo que tu sistema tenga mal; en un contenedor Linux eso es el ca-certificates de la distribución, que por lo general se actualizó el 20 de octubre.
Si tienes que llevar un bundle, trátalo como una dependencia de seguridad. Pin, Dependabot sin cooldown para certifi (Henry Schreiner avisa en el hilo de que los cooldowns te dejan vulnerable justo cuando no quieres), y el chequeo de huellas de arriba en el pipeline.
Asume que no hay revocación. El ssl de Python no consulta las CRL del RFC 5280 ni el OCSP del RFC 6960 a menos que lo configures, y casi nadie lo hace. Los Baseline Requirements del CA/Browser Forum obligan a la CA a revocar un intermedio comprometido en siete días (sección 4.9.1.2); eMudhra tardó unas quince horas desde el correo de Wayne (18:05 del 17 a 09:24 del 18), unas doce desde su acuse de recibo de las 21:29. Para los clientes que no consultan CRL dio igual, y los navegadores no esperaron a leerla: usan listas empujadas. Además, crl.emsign.com devolvía 403 al User-Agent por defecto de curl, y ocsp.emsign.com, ocsp-b.emsign.com y repository.emsign.com también rechazaban los de Wget y Python-urllib, según el comentario 44. Si un servicio te importa de verdad, pinea su clave pública o su emisor y asume la carga de rotarlo.
No cuentes con PEP 543. Era la propuesta de una API de TLS en Python independiente de OpenSSL, con verificadores nativos, y se retiró. truststore es lo que queda de esa idea, como biblioteca de terceros que parchea ssl.SSLContext.
Conclusión
No hubo criptografía rota ni un ataque sofisticado. Hubo, según eMudhra, un fallo en el script de una ceremonia que generó un segundo juego de claves con un byte de entropía, unas etiquetas parecidas que hicieron que se usara el equivocado, y un HSM que eMudhra dice certificado FIPS 140-2 nivel 3 y que no se negó a firmar con d = 4 (cómo es posible, sigue sin explicarse: comentarios 31 a 33 y 38). Y un auditor que firmó el informe anual. Dieciocho meses después, cualquiera con el certificado público podía ser EM DV TLS CA - G3B en 0,18 segundos. Antes de la revelación no sabemos si alguien lo fue; después sí: se registraron más de cien mil certificados firmados con esas claves, casi todos pruebas de concepto.
El ecosistema de navegadores lo cerró en horas porque tiene lo que hace falta: listas de intermedios bloqueados que llegan sin actualizar nada. El ecosistema Python tardó tres días en la primera versión útil y nueve en la que de verdad te protege aunque hayas olvidado un bundle, y lo hizo a base de una medida que CPython califica de extraordinaria: una lista de ocho hashes dentro del intérprete. certifi hizo lo único que podía hacer un fichero PEM, que es quitar raíces enteras. La 10.19 devolvió las tres no afectadas, pero la ECC G3 no vuelve, y según Chrome (comentario 30) el 99,9 % de los ~30.800 certificados vigentes bajo ella son de InCommon/Internet2: esas universidades siguen rotas hasta que migren a G1, a donde eMudhra las está llevando mientras anuncia que sale del negocio del TLS global (comentarios 37 y 61).
Si de todo esto te llevas una sola cosa: el bundle que verifica tus conexiones TLS es una copia congelada de una decisión de Mozilla, copiada a su vez dentro de cada herramienta, y nadie te avisa cuando caduca. Mira cuál usa cada proceso, pásale el chequeo de huellas, y donde puedas, deja que verifique el sistema operativo. Y si trabajas en un Mac, hoy, antes de hacer un git pull, comprueba qué dice /etc/ssl/cert.pem.