Назад к блогу

Первый URGENT-релиз Go: как устроена блокировка скомпрометированных корней и что успела сделать команда

Первый URGENT-релиз Go: как устроена блокировка скомпрометированных корней и что успела сделать команда

Экстренный выпуск Go 1.27.3 и 1.26.10 стал первым в истории проекта релизом по срочному треку — поводом послужили скомпрометированные ключи промежуточных сертификатов eMudhra, попавшие в чёрный список Mozilla. Разбираем, как именно устроена работа с корневыми сертификатами в `crypto/x509`: от ленивой загрузки системного пула и логики fallback-корней до фильтрации цепочек, где отбрасываются опасные сертификаты. Отдельно объясняем, почему на Linux команде пришлось выпускать патч самостоятельно, вместо того чтобы дожидаться обновления платформы.

17 октября Mozilla добавила восемь промежуточных сертификатов eMudhra в OneCRL, а 19 октября команда Go узнала, что ключи этих промежуточных сертификатов «trivially weak, allowing recovery of the private keys in microseconds». Через несколько часов вышли Go 1.27.3 и 1.26.10 — первый в истории проект релиз по «срочному» треку. Ниже — механика того, что именно изменилось в crypto/x509: как Go загружает корни, как строит и фильтрует цепочки, где именно отбрасываются сертификаты eMudhra и почему для Linux пришлось выпускать релиз, а не ждать обновления платформы.

Как Go загружает системные корни

Системные корневые сертификаты в Go хранятся в одном глобальном пуле. При первом обращении к нему запускается ленивая инициализация: once.Do(initSystemRoots) гарантирует, что загрузка выполнится ровно один раз при любом числе конкурентных вызовов, после чего закэшированный пул возвращается под RLock. Запись защищена отдельно — initSystemRoots и SetFallbackRoots берут Lock. Инициализация происходит не при старте программы, а при первом обращении, например из SystemCertPool, который возвращает копию пула через Clone.

Внутри initSystemRoots под Lock сохраняет прежний пул как fallbackRoots, затем загружает системные корни с диска и при ошибке обнуляет пул. Дальше проверяется, доступны ли системные сертификаты: пул считается доступным, если он непуст или помечен флагом systemPool — признаком того, что пул получен от системного верификатора платформы, а не собран из файлов. Если fallbackRoots был задан и системные сертификаты недоступны (или включён x509usefallbackroots), результат подменяется fallback-пулом.

SetFallbackRoots и порядок вызова

SetFallbackRoots задаёт набор корней, который используется, если не заданы кастомные корни и недоступен платформенный верификатор или системный пул — например, в контейнере без набора корневых сертификатов. Функция паникует при roots == nil и при повторном вызове.

Если системные корни ещё не загружены, переданный пул сразу кладётся в systemRoots, а useFallbackRoots выставляется по значению GODEBUG x509usefallbackroots. Загрузка с диска при этом намеренно не вызывается — чтобы не тратить CPU на старте программы. Если же системные корни уже загружены, функция проверяет их наличие и при отсутствии принудительного фолбэка и наличии системных сертификатов просто возвращается; иначе заменяет systemRoots на переданные корни и обнуляет ошибку.

Именно поэтому SetFallbackRoots должен вызываться до первого использования пула: при последующей инициализации initSystemRoots возьмёт сохранённый fallbackRoots и решит, заменить ли им загруженные системные корни. Принудительно включить фолбэк на всех платформах можно через GODEBUG=x509usefallbackroots=1 — это переключатель, который заставляет Go использовать fallback-пул даже при наличии системных сертификатов; на Windows и macOS он отключает платформенные API проверки и заставляет использовать чистый Go-верификатор. Без вызова SetFallbackRoots этот флаг не действует.

Откуда берутся корни на диске

Пути читаются из переменных окружения SSL_CERT_FILE и SSL_CERT_DIR. Если задан SSL_CERT_FILE, список файлов заменяется на единственный файл, и после первого успешно прочитанного файла цикл прерывается — остальные не рассматриваются. Для каталогов строка разбивается через filepath.SplitList: разделитель : выбран потому, что OpenSSL и BoringSSL применяют : на Unix-подобных системах и ; на Windows. Каждый каталог читается с фильтрацией: записи, для которых isSameDirSymlink вернула true, отбрасываются. Симлинком «внутри того же каталога» считается только запись с типом fs.ModeSymlink, чья цель не содержит разделитель пути, — такие исключаются, чтобы не прочитать один и тот же файл дважды.

Чем платформенные реализации отличаются

На Linux при загрузке корней init только расширяет список каталогов: если goos.IsAndroid == 1, к certDirectories добавляются пути системных и пользовательских корней Android. Сами пути заданы в двух переменных: certFiles — файлы, чтение останавливается после первого найденного, и certDirectories — каталоги, читаются все. В root_unix.go определён только systemVerify, который сообщает, что системная проверка цепочки не выполняется.

На Windows init заполняет карту windowsExtKeyUsageOIDs — соответствие расширенных назначений ключа NUL-терминированным байтам OID для передачи в Windows API. createStoreContext создаёт in-memory хранилище сертификатов, добавляет в него листовой сертификат и все промежуточные из opts.Intermediates. checkChainTrustStatus переводит статусы доверия в Go-ошибки: CERT_TRUST_IS_NOT_TIME_VALID в CertificateInvalidError{Expired}, CERT_TRUST_IS_NOT_VALID_FOR_USAGE в IncompatibleUsage, иначе — UnknownAuthorityError. verifyChain сначала проверяет статус, затем при наличии opts.DNSName — SSL-политику, извлекает цепочку и для ECDSA-родителей перепроверяет подписи для защиты от CVE-2020-0601.

Как CertPool хранит сертификаты

Пул держит сертификаты в двух структурах: срез lazyCerts с метаданными и функциями ленивого получения сертификата, и map byName, отображающий строку cert.RawSubject в срез индексов в lazyCerts. Каждый элемент lazyCerts хранит DER-encoded subject, функцию получения сертификата и необязательное constraint.

addCertFunc принимает сумму sha256.Sum224(cert.Raw), строку subject, функцию получения сертификата и constraint. Она паникует при getCert == nil, проверяет дубликат по haveSum и при повторе просто возвращает управление без добавления; иначе помечает сумму, добавляет запись в lazyCerts и дописывает индекс в byName[rawSubject].

Кандидаты в родители ищутся по s.byName[string(cert.RawIssuer)] — по совпадению Issuer проверяемого сертификата с Subject кандидата, потому что issuer сертификата и есть subject подписавшего его CA. Найденные кандидаты сортируются по правдоподобию: сначала совпадение SubjectKeyId кандидата с AuthorityKeyId проверяемого, затем случай, когда присутствует только один из ключей, затем несовпадение обоих.

AddCertWithConstraint и когда срабатывает ограничение

AddCertWithConstraint добавляет сертификат вместе с функцией, которая получает всю построенную цепочку и решает, валидна ли она. Внутри она вызывает addCertFunc, передавая сумму sha256.Sum224(cert.Raw), строку cert.RawSubject, функцию получения сертификата и сам constraint; тот сохраняется в поле lazyCert.constraint.

Ограничение вызывается во время Certificate.Verify, когда строится цепочка, корнем которой является этот сертификат. Если constraint возвращает не-nil ошибку, цепочка отбрасывается. Constraint может вызываться одновременно из нескольких горутин. В Verify после построения цепочек-кандидатов выполняется фильтрация через slices.DeleteFunc: при ошибке checkChainConstraints цепочка удаляется, а ошибка сохраняется в constraintsHintErr; если после фильтрации не осталось ни одной цепочки и constraintsHintErr не nil, возвращается именно эта ошибка.

AppendCertsFromPEM и неатомарность

AppendCertsFromPEM идёт по входным байтам циклом, декодируя очередной PEM-блок; на nil-блоке цикл прерывается. Блоки не типа CERTIFICATE или с заголовками пропускаются, блоки, чей ParseCertificate вернул ошибку, — тоже: невалидные блоки просто игнорируются, не прерывая обработку остальных. Для каждого успешно разобранного сертификата вызывается addCertFunc с ключом sha256.Sum224(cert.Raw) и отложенной функцией, которая при первом обращении заново разбирает те же байты. Возвращаемое ok становится true при каждом успешном добавлении, то есть означает лишь, что хотя бы один сертификат был успешно разобран.

Операция не атомарна: сертификаты добавляются в пул по одному по мере прохода цикла, поэтому при ошибке в середине уже добавленные ранее сертификаты остаются в пуле.

Как Verify строит и фильтрует цепочки

Verify сначала проверяет, что у листа и всех промежуточных есть Raw, затем при необходимости использует платформенный верификатор, иначе подставляет системные корни в opts.Roots и запускает проверку. Если opts.Roots.contains(c) истинно, цепочка сразу равна {{c}}; иначе запускается рекурсивный обход от листа к корню.

В обходе для текущего сертификата перебираются кандидаты-родители: сначала из корней (opts.Roots.findPotentialParents(c)), затем из промежуточных (opts.Intermediates.findPotentialParents(c)). Для каждого кандидата увеличивается счётчик проверок подписи — при превышении maxChainSignatureChecks = 100 обход прекращается с ошибкой errSignatureLimit. Кандидат пропускается, если у него нет публичного ключа или он уже есть в цепочке. alreadyInChain сравнивает не байты целиком, а RawSubject, RawSubjectPublicKeyInfo и значение расширения SAN — так обход не зацикливается на взаимных кросс-подписях. appendToFreshChain создаёт новый срез длиной len(chain)+1, копирует туда цепочку и добавляет сертификат в конец, не изменяя исходный срез.

Для корневого кандидата найденная цепочка добавляется в результат, для промежуточного — рекурсивно продолжается обход с расширенной цепочкой. Несколько цепочек возвращаются, когда найдено более одного пути — несколько корней или промежуточных с подходящим Subject. После этого Verify отбрасывает цепочки-кандидаты по четырём независимым признакам: по несоответствию certificate policies (граф политик), по непригодности расширенных назначений ключа (KeyUsage/ExtKeyUsage), по ошибке пользовательского ограничения, заданного при добавлении корня через AddCertWithConstraint, и по совпадению хеша открытого ключа с заблокированными SPKI eMudhra.

поиск по Issuer=Subjectсортировка по SKID/AKIDпроверка зацикливанияпроверка подписиограничение цепочкицепочка-кандидатполитики, назначения, SPKIвалидные цепочкиЛистfindPotentialParentsКандидатalreadyInChainCheckSignatureFromconstraintcandidateChainsФильтрыРезультат

Рисунок. Порядок проверок при построении одной цепочки-кандидата.

Проверка назначений ключа

checkChainForKeyUsage копирует запрошенные расширенные назначения и идёт по цепочке от корня к листу. Сертификат без ExtKeyUsage и UnknownExtKeyUsage не ограничивает назначения и пропускается. Если в ExtKeyUsage сертификата встречается ExtKeyUsageAny, этот сертификат пропускается как «явно годный для любого использования». Иначе для каждого ещё не вычеркнутого запрошенного назначения проверяется его наличие; несовпавшее вычёркивается, и когда не остаётся ни одного подходящего назначения, цепочка признаётся непригодной.

В Verify перед фильтрацией вычисляется anyKeyUsage: если среди opts.KeyUsages есть ExtKeyUsageAny, флаг ставится в true, и тогда проверка по назначениям не вызывается вовсе — присутствие anyExtendedKeyUsage переопределяет все остальные назначения. Если opts.KeyUsages пуст, он заменяется на []ExtKeyUsage{ExtKeyUsageServerAuth}. Флаги KeyUsage при этом игнорируются — в комментарии приводятся примеры CA, помечавших подписывающие сертификаты как годные только для шифрования, и корневой сертификат как негодный для подписи сертификатов.

Проверка certificate policies

Проверка реализована в policiesValid, которая строит граф политик и обходит цепочку от листа к trust anchor. Если пользователь не передал ни одной политики, это трактуется как передача anyPolicyOID (2.5.29.32.0) — в этом случае начальный набор заполняется этим OID. Граф инициализируется корневым узлом с validPolicy = anyPolicyOID; на каждом шаге incrDepth строит индекс родителей по ожидаемому набору политик текущего слоя и увеличивает глубину. При наличии anyPolicy в сертификате добавляются узлы для отсутствующих ожидаемых политик. prune удаляет узлы без детей на промежуточных слоях. Если у сертификата нет политик, граф обнуляется, и при explicitPolicy == 0 цепочка не проходит проверку.

containsDistrustedSPKIHash — где именно отбрасывается eMudhra

Функция containsDistrustedSPKIHash(chain) перебирает все сертификаты цепочки, для каждого вычисляет SHA-256 от RawSubjectPublicKeyInfo (сырые байты открытого ключа вместе с ASN.1-обёрткой), hex-кодирует хеш и ищет его в карте distrustedSPKIHashes; при первом совпадении цепочка помечается как содержащая заблокированный ключ. Карта содержит хеши SPKI промежуточных сертификатов eMudhra, у которых были найдены «trivially weak keys which could be easily recovered»; блокировка нужна «to prevent MITM attacks using said keys».

В Verify цепочки-кандидаты фильтруются через slices.DeleteFunc: если containsDistrustedSPKIHash(chain) истинно, элемент удаляется. Если после фильтрации candidateChains пуст, возвращается CertificateInvalidError{c, NoValidChains, ...} — либо constraintsHintErr, если он был установлен.

Проверка имени хоста

VerifyHostname сначала проверяет, является ли имя IP-адресом: если оно в квадратных скобках, они снимаются, затем выполняется netip.ParseAddr. При успехе адрес сравнивается только с полем IPAddresses через ip.Equal, иначе возвращается HostnameError. Для не-IP имён вход приводится к нижнему регистру через toLowerCaseASCII — явно ASCII-функцию, чтобы избежать Unicode-операций над DNS-метками (RFC 6125 6.4.1), — проверяется validHostnameInput и разбивается splitHostname с удалением завершающей точки.

Для каждого элемента DNSNames: если входное имя валидно и шаблон валиден (validHostnamePattern), вызывается matchHostnames, иначе — matchExactly. matchHostnames приводит шаблон к нижнему регистру, отвергает пустые pattern/hostParts, требует равного числа меток и разрешает * только как полную первую метку (пропускается), остальные метки сравнивает точно. matchExactly отвергает пустые строки и ., затем сравнивает обе стороны в нижнем регистре ASCII. validHostname отвергает пустую строку, одиночный * и пустые метки; в шаблоне допускает * только первой меткой; в метках разрешены a-z, 0-9, A-Z, - не в начале и _. Завершающую точку validHostnameInput снимает, validHostnamePattern — нет.

Email-подобные имена

parseRFC2821Mailbox разбирает адрес на локальную часть и домен. Если адрес начинается с ", локальная часть трактуется как quoted-string: цикл читает символы до закрывающей кавычки, при обратном слэше обрабатывается quoted-pair — следующий байт принимается, только если он равен 11, 12, 1–9 или 14–127, иначе возвращается ошибка; пустой ввод после \ — тоже ошибка. Пробел (32) принимается как qtext, хотя BNF его не допускает. Без кавычек локальная часть — это Atom (. Atom)*: разрешены буквы, цифры и набор спецсимволов, обратный слэш вне кавычек тоже принимается как экранирование, но пустая локальная часть отвергается. Затем проверяется, что локальная часть не начинается и не заканчивается точкой и не содержит двух точек подряд, после чего требуется @ и валидный домен без @.

domainToReverseLabels разбивает домен по точкам справа налево; пустая метка в конце (абсолютное имя) даёт ошибку, пустые метки в остальных позициях и символы вне диапазона 33–126 также отвергаются.

Ошибки и что видит клиент

Verify возвращает несколько типов ошибок. UnknownAuthorityError — когда издатель сертификата неизвестен. HostnameError — когда набор разрешённых имён не совпадает с запрошенным. CertificateInvalidError — при «необычной» ошибке; пользователям библиотеки рекомендуется обрабатывать все такие ошибки единообразно. SystemRootsError — при неудаче загрузки системных корней. InsecureAlgorithmError — когда алгоритм подписи небезопасен и подпись отклонена.

Текст HostnameError.Error() устроен так. Сначала проверяется legacy Common Name: если у сертификата нет SAN-расширения и Common Name совпадает с запрошенным хостом, возвращается «x509: certificate relies on legacy Common Name field, use SANs instead». Для IP при отсутствии IP SANs — «x509: cannot validate certificate for <host> because it doesn't contain any IP SANs»; при числе IP SANs не меньше 100 — «x509: certificate is valid for N IP SANs, but none matched <host>»; иначе перечисляются все IP SANs через запятую. Для не-IP хоста при числе DNSNames не меньше 100 — «x509: certificate is valid for N names, but none matched <host>»; если список пуст — «x509: certificate is not valid for any names, but wanted to match <host>»; иначе — «x509: certificate is valid for <список>, not <host>».

Базовый текст UnknownAuthorityError.Error() — «x509: certificate signed by unknown authority». Если задан hintErr, к нему добавляется пояснение с именем кандидата-удостоверяющего сертификата: имя берётся из Subject.CommonName, иначе из первого Subject.Organization, иначе как «serial:<номер>». SystemRootsError.Error() формирует «x509: failed to load system roots and no roots provided» и при непустом Err добавляет «; » и текст ошибки; Unwrap() возвращает поле Err, так что до исходной причины загрузки можно добраться.

Хронология инцидента

17 октября Mozilla добавила восемь промежуточных сертификатов eMudhra (шесть для TLS, два для подписи кода) в OneCRL, заблокировав их использование в Firefox. 19 октября команда Go узнала о выводах исследователей: ключи тривиально слабые, приватные ключи восстанавливаются за микросекунды.

Дальше по времени PDT: в 11:45 команду безопасности уведомил независимый внешний исследователь, сославшись на непубличные действия Mozilla и других организаций по удалению доверия к восьми промежуточным сертификатам eMudhra. В 12:30 Mozilla независимо подтвердила проблему и попросила не раскрывать её сразу, но выпустить исправление как можно скорее. В 14:00 был финализирован патч, блокирующий сертификаты, цепочка которых ведёт к скомпрометированным промежуточным. В 15:00 начался процесс выпуска минорных версий 1.27.3 и 1.26.10. В 19:13 релиз был завершён и публично анонсирован — на тот момент проблема ещё не была широко известна. Менее чем за семь с половиной часов с момента первого уведомления исправленные релизы были доступны по всему миру. К утру 20 октября многочисленные независимые исследователи публично раскрыли слабые ключи — уже после выхода релиза.

17 октMozilla добавляет 8 сертификатов в OneCRL19 окт 1145: Уведомление от внешнего исследователя19 окт 1230: Подтверждение от Mozilla19 окт 1400: Патч финализирован19 окт 1500: Начат выпуск 1.27.3 и 1.26.1019 окт 1913: Релиз анонсирован20 окт утроПубличное раскрытие исследователями

Рисунок. Последовательность событий инцидента eMudhra.

Почему релиз, а не CRL/OCSP

CRL и OCSP — это механизмы проверки отзыва сертификатов. На Linux нет единого встроенного механизма такой проверки. На Windows и macOS Go полагается на механизмы отзыва, которые предоставляют платформенные API, а на Linux аналогичного механизма нет. Поэтому, чтобы своевременно защитить пользователей на других платформах, которые могут не получить обновления платформы, Go выпустил срочный релиз, который аналогичным образом удалил доверие. Отсутствие такой поддержки было одной из главных причин ускоренного релиза — не оставлять пользователей Linux под угрозой дольше необходимого.

Как проверить свои сборки

Текущую версию тулчейна показывает go version, а версию Go, которой собран конкретный бинарь, — go version -m <path_to_your_binary>. Для проверки уязвимости бинаря используется govulncheck: его устанавливают с GOTOOLCHAIN=go1.27.3+auto, затем запускают в режиме -mode=binary с путём к бинарю.

$ GOTOOLCHAIN=go1.27.3+auto go install golang.org/x/vuln/cmd/govulncheck@latest
$ govulncheck -mode=binary <path_to_your_binary>

Бинарники, пересобранные с Go 1.27.3 и 1.26.10, автоматически отклоняют сертификаты, выпущенные скомпрометированными промежуточными сертификатами eMudhra.

Уроки и что дальше

Это был первый выпуск по срочному треку, и процесс занял чуть менее четырёх с четвертью часов — дольше желаемого. Раньше эти проблемы не проявлялись, потому что обычный выпуск растянут на несколько дней. После этого были внесены изменения для повышения эффективности выпуска, чтобы сократить время реакции на срочные угрозы. Исправления PRIVATE track анонсируются за три–семь дней; URGENT track идёт по более короткому графику, но выпуск можно было анонсировать в начале процесса, дав пользователям хотя бы несколько часов на подготовку.

Отдельно Go планирует приоритизировать работу по достижению паритета поддержки отзыва на Linux, включая исследование поддержки проекта upki (issue #82402). Безопасность для команды выходит за пределы исправления прямых уязвимостей в рантайме и библиотеках: нужно давать пользователям инструменты для написания безопасного ПО, смотреть в будущее криптографических алгоритмов и протоколов и проактивно реагировать на проблемы масштаба экосистемы.

Что из этого следует на практике

  • Блокировка живёт в коде, а не в данных. Скомпрометированные промежуточные сертификаты eMudhra отсекаются по хешу SPKI на этапе фильтрации цепочек в Verify. Это значит, что защита появляется ровно с версией тулчейна: бинарь, собранный старым компилятором, продолжит доверять этим цепочкам, даже если системное хранилище на машине уже обновлено.
  • Проверять нужно сам бинарь, а не только go version. go version показывает текущий тулчейн, а go version -m — версию, которой собран конкретный артефакт. Именно вторая величина определяет, есть ли в бинаре фильтр SPKI.
  • Fallback-корни — инструмент для контейнеров. SetFallbackRoots имеет смысл, когда системного пула нет; вызывать его нужно до первого использования пула, иначе ранняя ветка сохранения не сработает и загрузка с диска уже произойдёт. GODEBUG=x509usefallbackroots=1 без вызова SetFallbackRoots бесполезен.
  • Отзыв на Linux остаётся слабым местом. Пока паритет поддержки отзыва не сделан, единственный надёжный способ закрыть подобный инцидент для Linux-пользователей — пересборка с исправленным тулчейном.

Где смотреть в коде

Источники

Похожее