Как мы искали баг в поисковом Балансере, а нашли в Chromium
Некоторое время назад коллеги стали получать от пользователей жалобы на то, что иногда при использовании Поиска и Яндекс.Браузера они видят ошибку SSL connection error. Расследование того, почему это происходило, на мой взгляд, получилось интересным, поэтому я хочу поделиться им с вами. В процессе разбора ситуации мы несколько раз меняли «подозреваемый» софт, изучили множество дампов, вспомнили устройство машины состояний TLS и в итоге даже разбирались в коде Хромиума. Надеюсь, вам будет интересно это читать не меньше, чем нам было исследовать. Итак.
Через некоторое время у нас были записи логов ошибок и pcap-файлы со схожим содержимым:
Всё выглядит так, будто сервер ответил некорректно и клиент прекратил хендшейк. Проанализировав «корректные» (принятые клиентом) и «некорректные» ответы сервера, мы поняли, что они идентичны.
Анализ дампов показал, что проблема возникает только в случае использования клиентом TLS Ticket (механизм session reuse), и если при этом тикет был зашифрован не на ключе по умолчанию (в нашем случае получен до ротации ключей, но менее чем 28 часов назад).
Как я уже говорил — в Поиске используется свой Балансер, поэтому сначала мы стали искать ошибку в нем. Однако, позже предположили, что проблема может быть связана и с поведением клиента — она возникает, когда браузер пытается одновременно создать несколько SSL-соединений к веб-серверу. Такое поведение со стороны браузера (несколько соединений) со стороны браузера в общем случае (забудем про prefetch и пр.) может вызвать HTML вида:
<img src="https://domain.com/x"><img src="https://domain.com/y"><img src="https://domain.com/z">
Объединив эти теории, мы смогли воспроизвести проблему на связке Chromium + Nginx и поняли, что код Балансера ни при чем. Затем нам удалось окончательно выяснить причину такого поведения.
Дальше немного деталей про TLS и его клиентский state machine в реализации BoringSSL
Итак, как вы уже знаете, TLS-хендшейк бывает длинный и короткий.
При первом обращении к серверу длинный хендшейк с точки зрения клиента выглядит примерно так (я специально не стал прописывать обработку некоторых TLS extensions, чтобы было проще для понимания):
Состояния с префиксом SSL3_ST_CR — клиент читает сообщение (record) от сервера, с префиксом SSL3_ST_CW — клиент шлет сообщение на сервер. (Не так давно Chromium перешел на использование форка OpenSSL — Boringssl, поэтому все вышеописанные состояния справедливы для него.).
Посмотрим на структуру некоторых сообщений протокола TLS:
Назначение полей (опустил некоторые TLS extensions):
• Version — клиентская версия протокола (SSL 3.0 / TLS 1.0 / TLS 1.1 / TLS 1.2),
• Random — клиентский random,
• Session ID length — длина поля Session ID (0 при первом обращении),
• Session ID — идентификатор предыдущей сессии (пустой при первом обращении),
• SessionTicket TLS — TLS extension, Length — длина данных в расширении, Data -— значение.
(При первом обращении, соответственно, длина 0 и пустое значение).,
• Cipher Suites — поддерживаемые клиентом шифры,
• Server Name — SNI TLS extension, позволяющее сказать серверу, к какому именно домену обращается клиент.
Для того чтобы при следующем обращении не делать полный — «дорогой» и медленный — хендшейк, сервер может предложить клиенту воспользоваться одним из двух способов session reuse. Для этого он может вернуть клиенту в ServerHello либо Session ID, указывающий на сохраненный на стороне сервера state (RFC 5246), либо Session ID и Session Ticket TLS (RFC 5077). О них я несколько раз подробно рассказывал.
Так как RFC 5077 появился позже, он дополняет механизм сессий в RFC 5246 и внутри клиента строится вокруг одной и той же реализации. Сегодня разбираем только механизм TLS Tickets.
Назначение полей:
• Version — серверная версия протокола (SSL 3.0 / TLS 1.0 / TLS 1.1 / TLS 1.2),
• Random — серверный random,
• Session ID length — длина поля Session ID (при выдаче сервером нового тикета, должен быть выставлен в 0),
• Session ID — идентификатор предыдущей сессии (при первой выдаче тикета равен 0),
• SessionTicket TLS — TLS extension, наличие данного расширения означает, что сервер собирается выдать клиенту новый TLS Ticket, передав в состоянии ST_CR_FINISHED_A сообщение New Session Ticket и переведя сервер в состояние SSL3_ST_CR_SESSION_TICKET_A.
Назначение полей:
• Session Ticket Lifetime Hint — время жизни тикета, после которого он должен быть удален клиентом (клиент может сам решить сам, когда удалить тикет в пределах заданного периода времени, 0 — на усмотрение клиента),.
• Session Ticket Length — длина данных тикета,
• Session Ticket — значение тикета.
Значение и параметры тикета сохраняются в памяти клиента:
Следует отметить, что для клиента значение тикета — это ничего не значащий бинарный блоб, который нужно либо передавать на сервер, либо сохранять/обновлять при получении, референсным полем является Session ID. Сервер же использует первые 16 байт значения тикета для идентификации набора ключей, которые будут использоваться для проверки его целостности и расшифровки. Таким образом сервер может ротировать значения ключей, продолжая принимать от клиентов тикеты, выданные на старых ключах.
Так выглядит короткий хендшейк с использованием впервые выданного тикета:
где в ClientHello задаются следующие значения:
• Session ID length — длина поля Session ID (обычно 32 байта),
• Session ID — Ззначение Session ID из структуры SSL_SESSION,
• SessionTicket TLS — TLS extension, length — длина данных тикета, data — значение тикета.
В случае если тикет принят, сервер должен ответить в ServerHello таким образом, что
• Session ID length и Session ID равны соответствующим полям из ClientHello.
При этом если полученный тикет не будет обновлен сервером (используется текущий ключ), то поле SessionTicket TLS в ServerHello отсутствует.
Если же тикет был принят сервером, но ключ изменился, то хендшейк выглядит следующим образом:
Значения Session ID length и Session ID равны соответствующим полям из ClientHello, в ServerHello добавлено поле SessionTicket TLS. Это переводит клиент в состояние SSL3_ST_CR_SESSION_TICKET_A, и он ожидает сообщение New Session Ticket. Получив сообщение New Session Ticket, клиент проверяет, что значение Session ID из ServerHello равно сохраненному в SSL_SESSION, записывает значение Session Ticket в структуру SSL_SESSION и обновляет (!) значение Session ID, делая его равным результату хеш-функции SHA-256 от значения Session Ticket, выставляет состояние в SSL3_ST_CR_CHANGE.
Место в коде Chromium, отвечающее за session reuse, выглядело так:
Здесь GetSessionCacheKey() однозначно идентифицирует домен, порт, версию протокола. То есть для одного origin в пределах шарда всегда хранится не более одного экземпляра session.
Функция SSL_set_session() не копирует экземпляр session в заданное соединение, а передает в него указатель на этот экземпляр.
Таким образом, при инициализации, например, трех соединений подряд, клиент отправит одинаковые значения Session ID и SessionTicket TLS. Первое из соединений будет успешным и перейдет в состояние SSL3_ST_CR_SESSION_TICKET_A, после чего значение Session_ID будет изменено, а для второго и последующих клиент получит в ServerHello не пустой Session ID и, увидев, что значение, которое вернул сервер (то же самое, что прислал клиент), не равно значению в структуре SSL_SESSION (его уже изменило первое соединение), перейдет в состояние SSL3_ST_CR_CERT_A (полный хендшейк). Сервер, справедливо считая, что клиент ожидает от него новый тикет (SSL3_ST_CR_SESSION_TICKET_A), отправит сообщение New Session Ticket, что не соответствует ожидаемому состоянию и приведет к Unexpected message alert.
Проблема уже исправлена в Яндекс.Браузере 15.9 и Chromium 46.