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-интермедиатов. Следов эксплуатации пока не нашли.
Почему Go пришлось выпускать свой фикс
На 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 «блокирует доверие небольшому набору промежуточных сертификатов из-за проблем с их эксплуатацией». Обновиться призвали, но что именно искать, не сказали.
Треки security-релизов
В политике безопасности 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, вы доверяете всему, что он когда-то подписал. От этого спасает только блокировка по ключу или пиннинг.
Почему на Linux отзыв сертификатов почти не работает
На мой взгляд, это самый важный вывод из поста 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. Там их быстрее увидеть, чем на Хабре, так что если тема вам близка, заглядывайте.
Fedasiaz
Очень глубокий разбор. Код восстановления приватного ключа из публичного сертификата выглядит просто как магия, особенно учитывая, что это занимает доли секунды. Наглядно показывает, насколько критичной была ошибка на церемонии.
Отдельный респект за пункт про CI. Мысль про то, что
govulncheckи проверка версии компилятора должны крутиться на каждый коммит, а не раз в год по тревоге, — это золото. Я как раз разрабатываю инструмент (suturely), который автоматизирует обновление зависимостей и чинит сломанный код с помощью LLM. И такие инциденты, как с eMudhra, — отличный аргумент в пользу того, что процесс обновления и верификации должен быть максимально автоматизированным. Вручную в 3 часа ночи пересобирать сотни микросервисов и молиться, что новый тулчейн ничего не сломал — то еще удовольствие.Вопрос по практике: насколько сильно
govulncheckв режимеbinaryзамедляет пайплайн, если бинарников много? Вы гоняете его на каждый PR или вынесли в отдельную ночную джобу?