Семь с половиной часов: как Go закрывал дыру в ключах eMudhra и что сделать у себя
- четверг, 29 октября 2026 г. в 00:00:26
17 октября Mozilla молча добавила в OneCRL восемь промежуточных сертификатов eMudhra. Через двое суток вышли Go 1.27.3 и 1.26.10, которые этим сертификатам больше не доверяют. От первого письма в Go Security до готового релиза прошло меньше семи с половиной часов, и это первый релиз, выпущенный по новому треку URGENT.
26 октября Roland Shoemaker опубликовал в блоге Go разбор инцидента. Я его пересказал и дописал то, чего мне в нём не хватило. Как именно ломаются такие ключи (с кодом). Почему удалить корень недостаточно. Как прикрыть свой сервис, пока парк не пересобран. И почему на Linux с отзывом сертификатов всё так грустно.
Все восемь промежуточных CA висят под корнем emSign ECC Root CA – G3. Шесть выдают TLS-сертификаты (DV, OV, EV в поколениях G3B и G3C), два подписывают код. И у каждого есть кросс-подпись от Sectigo AAA Certificate Services. Это важно, вернусь к этому ниже.
Ключи генерировали на церемонии 30 апреля 2025 года. По версии eMudhra, скрипт по ошибке создал второй набор ключей с похожими метками, и в работу ушли именно они.
Насколько они плохие? Каждый приватный ключ меньше 256. В публикациях фигурировали значения 4, 56 и 115. Нормальный ключ для P-384 — случайное число примерно из ста десятичных знаков. Достать такой ключ из публичного сертификата можно на ноутбуке за долю секунды. Дальше выпускаешь сертификат на любой домен, и его примет любой клиент, который доверяет корню eMudhra или кросс-подписи Sectigo. Получается MITM без лишних усилий.
Как развивались события:
17 октября исследователь пишет в Apple, Google, Microsoft, Mozilla и eMudhra. Mozilla в тот же день сама восстанавливает ключи и блокирует сертификаты, Chrome тоже.
18 октября eMudhra отзывает сертификаты.
19 октября реагирует Apple, выходит релиз Go.
20 октября реагирует Microsoft, а утром того же дня слабость ключей раскрывают публично.
27 октября до пользователей Chrome доезжает удаление корня eMudhra.
В логах Certificate Transparency нет ни одного сертификата от шести TLS-интермедиатов. Следов эксплуатации пока не нашли.
На Windows и macOS crypto/x509 отдаёт проверку цепочки системе (CryptoAPI и Security.framework). Microsoft и Apple выкатили блокировку, и Go-программы на этих ОС получили её бесплатно.
На Linux такого API нет. Go берёт корни из /etc/ssl/certs и проверяет цепочку сам, без CRL и без OCSP. А большая часть продового Go крутится именно на Linux. Обновления ca-certificates тут мало (почему, разберу ниже), так что команда зашила запрет на скомпрометированные интермедиаты прямо в рантайм.
19 октября, время PDT:
Время | Что произошло |
|---|---|
11:45 | Внешний исследователь сообщает в Go Security, что Mozilla и другие втихую блокируют восемь CA eMudhra. Команда быстро понимает, что дело в слабых ключах |
12:30 | Mozilla пишет сама: просит пока ничего не раскрывать, но выпустить фикс как можно скорее |
14:00 | Патч готов. Исследователь присылает тестовые сертификаты, подписанные восстановленными ключами |
15:00 | Начинается выпуск 1.27.3 и 1.26.10 |
19:13 | Релиз опубликован |
утро 20 октября | Ключи раскрыты публично |
Сам релизный процесс занял чуть больше четырёх часов. Анонс сделали нарочно расплывчатым: Go «блокирует доверие небольшому набору промежуточных сертификатов из-за проблем с их эксплуатацией». Обновиться призвали, но что именно искать, не сказали.
В политике безопасности Go теперь три трека. PUBLIC для мелочей, которые едут в обычных минорных релизах. PRIVATE для серьёзного: фикс готовят закрыто и анонсируют за 3–7 дней. И новый URGENT для случаев, когда ждать нельзя.
Первый же прогон URGENT показал, что релизный процесс местами тормозит. Пока релиз занимал несколько дней, этого никто не замечал. Команда обещает ускориться и анонсировать URGENT-релизы в момент старта, чтобы у пользователей было хотя бы несколько часов в запасе. Если у вас Go в проде, подпишитесь на golang-announce, иначе такой анонс легко пропустить.
Пересобрать всё, что ходит по TLS, на Go 1.27.3 или 1.26.10. Не только код, который сам дёргает net/http, но и всё, что ходит через gRPC, облачные SDK и драйверы БД. Верификатор живёт внутри бинарника, так что обновить 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@latest govulncheck -mode=binary ./bin/my-service
И обновить базовые образы. В Debian ca-certificates уже исправлен: 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}') [ -n "$v" ] && echo "$v $f" done | sort -V
На этом пересказ заканчивается, дальше мои дополнения.
Корень у eMudhra эллиптический, значит ключи ECDSA. Публичный ключ — точка Q = d·G, где G — базовая точка кривой, а d — приватный скаляр. Восстановить d по Q нельзя, пока d выбран случайно из всего диапазона (для P-384 это около 2³⁸⁴). Если же d < 256, «дискретный логарифм» превращается в цикл на 255 итераций.
Хотелось увидеть это своими глазами, поэтому я сделал CA с ключом d = 115:
// gen/main.go — самоподписанный CA с d = 115 на P-384. Только для экспериментов. package main import ( "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-endian d[47] = 115 ek, 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, пока d·G не совпадёт с Q package main import ( "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) / 8 scalar := make([]byte, size) const limit = 1 << 24 for 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.pem Totally Secure TLS CA - G3X: приватный ключ найден, d = 115 real 0m0.270s
Почти все эти 0,27 секунды уходят на компиляцию. Так что «микросекунды» в посте Go не фигура речи.
Тот же перебор пригодится, если у вас есть внутренний 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
Нужный промежуточный сертификат атакующий сам положит в рукопожатие.
Поэтому блокировать надо ключи интермедиатов, а не отдельные сертификаты и не корень. Так поступили и Node.js, и Go. Удобнее всего опознавать ключ по SPKI (SubjectPublicKeyInfo): у исходного и кросс-подписанного сертификатов разные издатели, серийники и подписи, а ключ один.
Допустим, пересобрать всё за час не получится. Или хочется иметь страховку на случай, если в следующий раз релиз Go выйдет позже, чем начнутся атаки. В crypto/tls для этого есть хук VerifyConnection. Он срабатывает после стандартной проверки цепочки, когда VerifiedChains уже заполнены:
package main import ( "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 пустой, и хук ничего не проверит, причём молча.
VerifyPeerCertificate для этой задачи хуже: при возобновлении сессии он не вызывается. VerifyConnection вызывается всегда.
И главное: хук должен стоять везде. gRPC (credentials.NewTLS(cfg)), драйверы БД, облачные SDK часто собирают свой tls.Config. Проще всего держать одну фабрику конфигов во внутреннем пакете, а &tls.Config{} в остальном коде запретить через forbidigo.
Пересборку на свежем Go это не заменяет. Зато блокировку можно выкатить конфигом за минуты, не дожидаясь, пока пересоберётся весь парк.
Можно зайти с другой стороны и не зависеть от /etc/ssl/certs.
x509.SetFallbackRoots вместе с golang.org/x/crypto/x509roots/fallback встраивает в бинарник корни Mozilla. Обновляются они вместе с golang.org/x/crypto. Но это именно запасной вариант: он работает, только если системных корней нет (scratch-образы, экзотические платформы).
Для внутренних сервисов честнее задать RootCAs в tls.Config явно. Если сервис ходит только к своим, доверять сотне публичных корней ему незачем. Пул из одного-двух своих CA, и история вроде eMudhra вас не касается.
Но помните про кросс-подписи: доверяя Sectigo AAA, вы доверяете всему, что он когда-то подписал. От этого спасает только блокировка по ключу или пиннинг.
На мой взгляд, это самый важный вывод из поста Go: на Linux нет общего механизма отзыва сертификатов. Go собирается это исправить, обсуждение идёт в golang/go#82402.
Классические варианты не подходят. CRL большие, у каждого CA свой список, и далеко не все CA следят за их доступностью. OCSP означает лишний запрос на каждое соединение и утечку того, куда вы ходите. А в режиме soft-fail атакующий, который уже сидит посередине, просто блокирует и OCSP. Let’s Encrypt от OCSP уже отказался, CA/B Forum сделал его необязательным. OCSP stapling зависит от сервера, а в нашем сценарии сервер принадлежит атакующему.
Браузеры давно делают по-другому: вендор сам собирает отзывы и присылает клиенту компактную базу. У Firefox это OneCRL для интермедиатов и CRLite (каскад Bloom-фильтров) для всего остального, у Chrome — CRLSets. Поэтому браузеры были защищены уже 17 октября, а Go на Linux только 19-го, и то после пересборки.
Go присматривается к upki. Это проект из организации rustls, который развивают вместе с Canonical (анонс на Ubuntu Discourse). Идея та же, что у CRLite: общий для всех TLS-стеков механизм отзыва, быстрый и не сливающий историю запросов. Если взлетит, в следующий раз хватит обновить данные, а не пересобирать все бинарники в спешке.
Состояние на момент написания:
Экосистема | Исправление |
|---|---|
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 стало можно подделать за микросекунды.
Удалить корень ещё не значит отозвать доверие. Пока жива кросс-подпись, путь до доверенного корня остаётся.
На Linux Go-сервис защищён ровно настолько, насколько свежий у него компилятор. go version -m и govulncheck -mode=binary должны крутиться в CI постоянно, а не запускаться руками раз в год по тревоге.
Единая фабрика tls.Config с VerifyConnection стоит пары часов работы и однажды может сэкономить ночь.
Go и Mozilla в этот раз успели раньше, чем ключи стали публичными. В следующий раз окно может оказаться короче.
Источники: пост в блоге Go, Help Net Security, Rescana, Inc42, обсуждение в Mozilla dev-security-policy.
Подобные разборы, заметки про Go и свежие новости экосистемы я выкладываю в Telegram-канал @Golang_google. Там их быстрее увидеть, чем на Хабре, так что если тема вам близка, заглядывайте.