17 октября 2026 года Mozilla без объяснений внесла в OneCRL восемь промежуточных сертификатов удостоверяющего центра eMudhra. Через двое суток команда безопасности Go выпустила 1.27.3 и 1.26.10, которые перестали доверять этим сертификатам. От первого сообщения до опубликованного релиза прошло меньше семи с половиной часов. Это первый релиз Go, выпущенный по новому треку URGENT.
26 октября Roland Shoemaker из Go Security опубликовал разбор инцидента. Ниже его пересказ на русском и несколько тем, которых в оригинале нет: насколько слабыми были ключи (с кодом, который их восстанавливает), почему не помогает просто удалить корень, как защитить собственный Go-сервис, не дожидаясь релиза, и что не так с отзывом сертификатов в Linux.
Что произошло
Восемь промежуточных CA eMudhra выпущены от корня emSign ECC Root CA – G3. Шесть из них выдают TLS-сертификаты (DV, OV и EV, по два поколения G3B/G3C), ещё два подписывают код. Кроме того, у каждого из восьми есть кросс-подпись от корня Sectigo AAA Certificate Services.
Ключи этих CA генерировали на церемонии 30 апреля 2025 года. По предварительному объяснению eMudhra, ошибка в скрипте создала второй, слабый набор ключей. Метки у них были почти такие же, как у правильных, и сотрудники по ошибке использовали именно эти ключи.
«Слабый» здесь значит буквально следующее: каждый из восьми приватных ключей меньше 256. Среди опубликованных значений были 4, 56 и 115. Нормальный приватный ключ — случайное число длиной около сотни десятичных цифр.
Такие ключи восстанавливаются из публичного сертификата за доли секунды на ноутбуке. Владелец такого ключа может выпустить валидный TLS-сертификат на любой домен. Клиенты, которые доверяют корню eMudhra или кросс-подписи Sectigo, примут такой сертификат, и это готовый MITM.
Кратко о хронологии:
-
17 октября. Исследователь сообщает о проблеме Apple, Google, Microsoft, Mozilla и eMudhra. Mozilla в тот же день самостоятельно восстанавливает ключи и блокирует сертификаты через OneCRL. Chrome тоже блокирует их в день сообщения.
-
18 октября. eMudhra отзывает сертификаты.
-
19 октября. Apple, релиз Go.
-
20 октября. Microsoft. Утром того же дня несколько независимых исследователей публично раскрывают слабость ключей.
-
27 октября. Удаление корня eMudhra из Chrome Root Store доходит до пользователей.
В логах Certificate Transparency нет сертификатов, выпущенных шестью TLS-интермедиатами. Активной эксплуатации пока не обнаружено.
Почему Go пришлось вмешиваться
Проверка сертификатов в Go устроена по-разному на разных платформах:
-
Windows и macOS:
crypto/x509делегирует проверку цепочки системе (CryptoAPI и Security.framework). Когда Microsoft и Apple выкатили отзыв, Go-программы на этих ОС получили его автоматически. -
Linux и прочие: системного API для проверки цепочек нет. Go берёт корни из системного хранилища (
/etc/ssl/certs/...) и проверяет цепочку своим верификатором. Отзыв (CRL, OCSP) он не проверяет.
Поэтому на Linux, где работает большая часть продового Go, кроме Go-рантайма закрыть дыру было нечем. Обновление ca-certificates помогает только частично (подробнее ниже). Команда выпустила релиз, который жёстко запрещает цепочки через скомпрометированные промежуточные CA.
Хронология релиза
Время тихоокеанское (PDT), 19 октября:
|
Время |
Событие |
|---|---|
|
11:45 |
Внешний исследователь сообщает Go Security о неанонсированных действиях Mozilla и других против восьми CA eMudhra. Команда быстро убеждается, что причина в слабых ключах |
|
12:30 |
Mozilla независимо сообщает о проблеме и просит не раскрывать детали, но выпустить исправление как можно быстрее |
|
14:00 |
Патч готов. Исследователь присылает тестовые сертификаты, подписанные восстановленными ключами |
|
15:00 |
Начат выпуск Go 1.27.3 и 1.26.10 |
|
19:13 |
Релиз опубликован и анонсирован |
|
утро 20 октября |
Слабые ключи раскрыты публично |
Сам выпуск по треку URGENT занял чуть больше четырёх часов. Анонс был намеренно расплывчатым: Go «блокирует доверие небольшому набору промежуточных сертификатов из-за конкретных проблем с их эксплуатацией». Так защищающиеся получили сигнал обновиться, а атакующие не узнали, что именно искать.
Треки security-релизов Go
У Go есть политика безопасности с треками:
-
PUBLIC: малозначимые проблемы, исправляются в обычных минорных релизах.
-
PRIVATE: серьёзные уязвимости. Исправление готовят закрыто, релиз анонсируют за 3–7 дней.
-
URGENT: новый трек для ситуаций, когда эксплуатация вот-вот начнётся и предупреждать заранее некогда.
Первый прогон URGENT вскрыл узкие места в релизном процессе. Пока релиз занимал несколько дней, они были незаметны. Команда уже их устраняет и предлагает анонсировать URGENT-релизы в момент старта процесса, чтобы у пользователей было хотя бы несколько часов на подготовку. Если у вас есть Go-сервисы, имеет смысл подписаться на golang-announce, чтобы такой анонс не прошёл мимо.
Что сделать прямо сейчас
-
Пересобрать всё, что ходит по TLS (напрямую через
net/http/crypto/tlsили через gRPC, SDK облаков, драйверы БД), компилятором Go 1.27.3 или 1.26.10 либо новее. Обновить рантайм на сервере недостаточно: верификатор зашит в бинарник. Go 1.25 и более ранние версии исправления не получат. -
Проверить, чем собраны бинарники:
go version # версия тулчейнаgo version -m ./bin/my-service # какой Go собрал бинарник
-
Прогнать
govulncheckпо готовым бинарникам:
GOTOOLCHAIN=go1.27.3+auto go install golang.org/x/vuln/cmd/govulncheck@latestgovulncheck -mode=binary ./bin/my-service
-
Обновить базовые образы.
ca-certificatesв Debian исправлен (20250419+deb13u1для trixie,20250419~deb12u2для bookworm). Distroless- и scratch-образы, в которые хранилище корней скопировано на этапе сборки, придётся пересобрать.
Для инвентаризации большого парка удобно пройтись по всем бинарникам разом:
find /usr/local/bin /opt -type f -perm -u+x 2>/dev/null | while read -r f; do v=$(go version "$f" 2>/dev/null | awk '{print $NF}') || continue [ -n "$v" ] && echo "$v $f"done | sort -V
Дальше идут темы, которых нет в оригинальном посте.
Насколько слабым был ключ: восстанавливаем его на Go
Корень emSign ECC Root CA – G3 эллиптический, значит речь о ECDSA. Публичный ключ здесь — точка на кривой Q = d·G, где G — фиксированная базовая точка, а d — приватный скаляр. Стойкость держится на том, что по Q нельзя найти d за разумное время, если d выбран равномерно из всего диапазона (около 2³⁸⁴ для P-384). Если d < 256, задача дискретного логарифма сводится к перебору 255 вариантов.
Чтобы показать это на практике, сгенерируем «удостоверяющий центр» с ключом d = 115:
// gen/main.go — самоподписанный CA с d = 115 на P-384 (только для демонстрации!)package mainimport ("crypto/ecdh""crypto/ecdsa""crypto/elliptic""crypto/rand""crypto/x509""crypto/x509/pkix""encoding/pem""math/big""os""time")func main() {d := make([]byte, 48) // скаляр P-384 — 48 байт big-endiand[47] = 115ek, err := ecdh.P384().NewPrivateKey(d)if err != nil {panic(err)}x, y := elliptic.Unmarshal(elliptic.P384(), ek.PublicKey().Bytes())priv := &ecdsa.PrivateKey{PublicKey: ecdsa.PublicKey{Curve: elliptic.P384(), X: x, Y: y},D: new(big.Int).SetBytes(d),}tmpl := &x509.Certificate{SerialNumber: big.NewInt(1),Subject: pkix.Name{CommonName: "Totally Secure TLS CA - G3X"},NotBefore: time.Now(),NotAfter: time.Now().Add(24 * time.Hour),IsCA: true,BasicConstraintsValid: true,KeyUsage: x509.KeyUsageCertSign,}der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)if err != nil {panic(err)}pem.Encode(os.Stdout, &pem.Block{Type: "CERTIFICATE", Bytes: der})}
Теперь восстановим ключ, зная только сертификат. Используем crypto/ecdh и метод (*ecdsa.PublicKey).ECDH() (Go 1.20+), чтобы не трогать устаревший crypto/elliptic:
// recover/main.go — перебор маленьких d: Q == d·G ?package mainimport ("bytes""crypto/ecdsa""crypto/x509""encoding/pem""fmt""log""os")func main() {raw, err := os.ReadFile(os.Args[1])if err != nil {log.Fatal(err)}block, _ := pem.Decode(raw)cert, err := x509.ParseCertificate(block.Bytes)if err != nil {log.Fatal(err)}pub, ok := cert.PublicKey.(*ecdsa.PublicKey)if !ok {log.Fatal("не ECDSA-ключ")}target, err := pub.ECDH()if err != nil {log.Fatal(err)}curve := target.Curve()size := (pub.Curve.Params().BitSize + 7) / 8scalar := make([]byte, size)const limit = 1 << 24for d := uint64(1); d < limit; d++ {for i := 0; i < 8; i++ {scalar[size-1-i] = byte(d >> (8 * i))}k, err := curve.NewPrivateKey(scalar)if err != nil {continue}if bytes.Equal(k.PublicKey().Bytes(), target.Bytes()) {fmt.Printf("%s: приватный ключ найден, d = %d\n", cert.Subject.CommonName, d)return}}fmt.Printf("%s: d < %d не найден\n", cert.Subject.CommonName, limit)}
$ go run ./gen > weak.pem && time go run ./recover weak.pemTotally Secure TLS CA - G3X: приватный ключ найден, d = 115real 0m0.270s
Почти всё это время уходит на компиляцию. Так что фраза «ключи восстанавливались за микросекунды» из поста Go вовсе не преувеличение.
Этот же код можно использовать как дешёвый санити-чек для собственного PKI. Если у вас есть внутренний CA, сгенерированный самописным скриптом, прогоните его сертификаты через такой перебор, а заодно через badkeys, который ловит известные классы слабых ключей (Debian OpenSSL 2008, ROCA, общие простые множители у RSA и т. п.). Если проверка ничего не нашла, ключ от этого не становится стойким. Но если нашла, разбор на этом можно заканчивать.
Почему недостаточно удалить корень
Первое, что приходит в голову: убрать emSign ECC Root CA – G3 из хранилища, и дело сделано. Mozilla так и поступила, NSS 3.131.1 удаляет этот корень. Но у каждого из восьми интермедиатов есть кросс-подпись от Sectigo AAA Certificate Services, а это старый и очень распространённый корень. Клиент без корня eMudhra всё равно построит цепочку
leaf (злоумышленника) → EM DV TLS CA - G3B → [кросс-подпись] → AAA Certificate Services
если атакующий положит в TLS-рукопожатие нужный промежуточный сертификат, а это полностью в его руках.
Поэтому заблокировать нужно сами ключи интермедиатов, а не конкретные сертификаты и не корень. Отсюда и подход Node.js (отвергать цепочки, содержащие заблокированные ключи) и Go. Блокировать нужно по SPKI (SubjectPublicKeyInfo): у кросс-подписанного и исходного сертификата CA разные издатели, серийники и подписи, а публичный ключ один и тот же.
Своя защита в Go: deny-list по SPKI
Предположим, вы не можете пересобрать всё за час. Или вы хотите иметь «аварийный рубильник» на будущее, на случай если следующий инцидент случится раньше релиза. В crypto/tls для этого есть хук VerifyConnection. Он вызывается после штатной проверки цепочки, когда VerifiedChains уже заполнены:
package mainimport ("crypto/sha256""crypto/tls""crypto/x509""encoding/hex""fmt""log""net/http""time")// SHA-256 от SubjectPublicKeyInfo скомпрометированных CA.// Значения берите из официального списка затронутых интермедиатов,// здесь заглушка.var distrustedSPKI = map[string]bool{"0000000000000000000000000000000000000000000000000000000000000000": true,}func spkiHash(c *x509.Certificate) string {h := sha256.Sum256(c.RawSubjectPublicKeyInfo)return hex.EncodeToString(h[:])}func denyCompromisedCAs(cs tls.ConnectionState) error {for _, chain := range cs.VerifiedChains {for _, c := range chain {if distrustedSPKI[spkiHash(c)] {return fmt.Errorf("tls: цепочка содержит недоверенный CA %q", c.Subject.CommonName)}}}return nil}func main() {tr := http.DefaultTransport.(*http.Transport).Clone()tr.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12,VerifyConnection: denyCompromisedCAs,}client := &http.Client{Transport: tr, Timeout: 10 * time.Second}resp, err := client.Get("https://go.dev/")if err != nil {log.Fatal(err)}defer resp.Body.Close()fmt.Println(resp.Status)}
Несколько важных деталей:
-
Хешируйте
RawSubjectPublicKeyInfo, а не сертификат целиком. Только так одним правилом покрываются и исходный, и кросс-подписанный вариант интермедиата. -
Проверяйте все цепочки из
VerifiedChains. Верификатор может найти несколько путей к доверенным корням. Здесь выбрана консервативная политика: если хоть один путь проходит через плохой CA, соединение отвергается. -
При
InsecureSkipVerify: trueмассивVerifiedChainsпуст, и хук молча ничего не проверит. Это ещё одна причина держатьInsecureSkipVerifyподальше от прода. -
VerifyPeerCertificateв этой ситуации хуже: при возобновлении сессии он не вызывается, аVerifyConnectionвызывается в том числе при resumption. -
Хук нужно подключить везде, а не только в своём
http.Client. gRPC (credentials.NewTLS(cfg)), драйверы БД, SDK облаков часто создают своиtls.Config. Удобнее всего держать фабрикуtls.Configв одном внутреннем пакете и запретить&tls.Config{}в остальных местах линтером (например, правиломforbidigo).
Это дополнение к обновлению Go, а не его замена. Зато такой рубильник можно выкатить конфигом, не дожидаясь пересборки всего парка.
Свой набор корней вместо системного
Есть и противоположная стратегия: не полагаться на /etc/ssl/certs и явно задавать корни в коде. В Go для этого есть два инструмента.
-
x509.SetFallbackRootsи пакетgolang.org/x/crypto/x509roots/fallback. Встраивает в бинарник набор корней Mozilla, который используется, когда системного хранилища нет (scratch-контейнеры, экзотические платформы). Хранилище обновляется вместе с зависимостьюgolang.org/x/crypto: поднимаете версию, пересобираете, и корень пропадает. Обратите внимание, это именно fallback: если системные корни есть, используются они. -
Явный
RootCAsвtls.Config. Для внутренних сервисов, которые общаются только со своими эндпоинтами, нет смысла доверять сотне публичных корней. Пул из одного-двух собственных CA резко уменьшает поверхность атаки, и инциденты вроде eMudhra вас просто не касаются.
Но учтите урок про кросс-подписи: если вы доверяете Sectigo AAA, вы доверяете и всему, что он когда-то кросс-подписал. Только явный deny-list по ключам (как выше) или пиннинг закрывают этот вектор.
Отзыв сертификатов в Linux: почему его фактически нет
Самый важный вывод поста Go в том, что на Linux не существует единого механизма отзыва, и Go планирует это исправить (golang/go#82402).
Классические способы плохо подходят:
-
CRL. Списки отзыва большие, их нужно скачивать и обновлять. Многие CA публикуют их без особой заботы о доступности. Кроме того, у каждого CA свой список.
-
OCSP. Запрос к CA при каждом соединении: лишняя задержка и утечка информации о том, куда вы ходите. В режиме soft-fail атакующий, способный сделать MITM, легко блокирует и OCSP. Let’s Encrypt уже отказался от OCSP в пользу CRL, а CA/B Forum сделал OCSP необязательным.
-
OCSP stapling зависит от сервера, а в описанном сценарии сервер принадлежит атакующему.
Браузеры давно решают эту задачу иначе: вендор агрегирует отзывы и доставляет клиенту компактную структуру. В Firefox это OneCRL (для интермедиатов) и CRLite (для всех сертификатов, на каскаде Bloom-фильтров), в Chrome — CRLSets. Именно поэтому Firefox и Chrome были защищены 17 октября, а Go на Linux — только после 19-го, и только после пересборки.
Go рассматривает upki из организации rustls (проект развивается вместе с Canonical, см. анонс на Ubuntu Discourse). Цель проекта — дать Linux надёжный, приватный и эффективный механизм отзыва в духе CRLite, общий для разных TLS-стеков. Если всё получится, следующий подобный инцидент на Linux закроют обновлением данных, а не экстренной пересборкой всех бинарников.
Как обстоят дела в других экосистемах
Для полноты картины, на момент написания:
|
Экосистема |
Исправление |
|---|---|
|
Go |
1.27.3 / 1.26.10, нужна пересборка (CVE-2026-111943) |
|
Node.js |
22.23.4, 24.22.1, 26.12.1, нужен рестарт |
|
Python certifi |
2026.10.19 (pip 26.2.2, uv 0.13.4+) |
|
Rust |
webpki-roots / webpki-root-certs 1.0.10, rustls-webpki 0.103.16 |
|
PHP Composer ca-bundle |
1.6.0, 1.4.16 |
|
NSS |
3.131.1 (корень удалён) |
|
Debian ca-certificates |
20250419+deb13u1, 20250419~deb12u2 |
|
Firefox / Thunderbird |
158.0.1, ESR 153.5.1 / 115.43.1 |
Java и голый OpenSSL в этот список не попали: проверяйте бюллетени своего вендора. Если вы зависите от ca-certificates дистрибутива, оцените, достаточно ли удаления корня с учётом кросс-подписей (см. выше).
Выводы
-
Слабый ключ — это не обязательно «мало бит в RSA». Ошибка в одном скрипте на ключевой церемонии сделала восемь CA подделываемыми за микросекунды. Проверяйте ключи своего PKI хотя бы простым перебором и badkeys.
-
Удаление корня не равно отзыву: кросс-подписи сохраняют путь доверия. Блокировать нужно по ключу (SPKI).
-
На Linux Go-приложение защищено ровно настолько, насколько свеж компилятор, которым оно собрано. Инвентаризация бинарников (
go version -m,govulncheck -mode=binary) должна быть процессом, а не реакцией на инцидент. -
Держите аварийный рубильник: централизованная фабрика
tls.ConfigсVerifyConnectionдаст выкатить блокировку конфигом. -
Подпишитесь на golang-announce: следующий URGENT-релиз могут анонсировать всего за несколько часов.
Команда Go и Mozilla сработали образцово: патч вышел раньше, чем ключи стали публичными. Но в следующий раз временное окно может оказаться короче, так что готовиться к этому стоит заранее.
Источники: Our Response To The eMudhra Key Disclosure Incident — The Go Blog · Help Net Security · Rescana · Inc42 · Mozilla dev-security-policy
ссылка на оригинал статьи https://habr.com/ru/articles/1100286/