Семь с половиной часов: как Go закрывал инцидент с ключами eMudhra (и что сделать у себя)

—

от автора

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, чтобы такой анонс не прошёл мимо.

Что сделать прямо сейчас

  1. Пересобрать всё, что ходит по TLS (напрямую через net/http/crypto/tls или через gRPC, SDK облаков, драйверы БД), компилятором Go 1.27.3 или 1.26.10 либо новее. Обновить рантайм на сервере недостаточно: верификатор зашит в бинарник. Go 1.25 и более ранние версии исправления не получат.

  2. Проверить, чем собраны бинарники:

go version                      # версия тулчейнаgo version -m ./bin/my-service  # какой Go собрал бинарник
  1. Прогнать govulncheck по готовым бинарникам:

GOTOOLCHAIN=go1.27.3+auto go install golang.org/x/vuln/cmd/govulncheck@latestgovulncheck -mode=binary ./bin/my-service
  1. Обновить базовые образы. 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/