Как мы искали баг в поисковом Балансере, а нашли в Chromium

Некоторое время назад коллеги стали получать от пользователей жалобы на то, что иногда при использовании Поиска и Яндекс.Браузера они видят ошибку SSL connection error. Расследование того, почему это происходило, на мой взгляд, получилось интересным, поэтому я хочу поделиться им с вами. В процессе разбора ситуации мы несколько раз меняли «подозреваемый» софт, изучили множество дампов, вспомнили устройство машины состояний TLS и в итоге даже разбирались в коде Хромиума. Надеюсь, вам будет интересно это читать не меньше, чем нам было исследовать. Итак.

e0ea29a67019461983eeaf7315bf5015.png

Через некоторое время у нас были записи логов ошибок и pcap-файлы со схожим содержимым:

bf9d8f3d477f4484b395311bb8e9f9ac.png

Всё выглядит так, будто сервер ответил некорректно и клиент прекратил хендшейк. Проанализировав «корректные» (принятые клиентом) и «некорректные» ответы сервера, мы поняли, что они идентичны.
Анализ дампов показал, что проблема возникает только в случае использования клиентом 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, чтобы было проще для понимания):

0997e33002df4ef498fcf6828f1486a5.png

Состояния с префиксом SSL3_ST_CR — клиент читает сообщение (record) от сервера, с префиксом SSL3_ST_CW — клиент шлет сообщение на сервер. (Не так давно Chromium перешел на использование форка OpenSSL — Boringssl, поэтому все вышеописанные состояния справедливы для него.).

Посмотрим на структуру некоторых сообщений протокола TLS:

b531e34003e24dd7a16729c192013e5e.png

Назначение полей (опустил некоторые 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.

d5112f8c5df3464c87624ee59e3ec676.png

Назначение полей:
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.

2c01db21196c4a31b78465e0cd8019da.png

Назначение полей:
Session Ticket Lifetime Hint — время жизни тикета, после которого он должен быть удален клиентом (клиент может сам решить сам, когда удалить тикет в пределах заданного периода времени, 0 — на усмотрение клиента),.
Session Ticket Length — длина данных тикета,
Session Ticket — значение тикета.

Значение и параметры тикета сохраняются в памяти клиента:

6b1049eb6467497eaa63721e6a0f871c.png

Следует отметить, что для клиента значение тикета — это ничего не значащий бинарный блоб, который нужно либо передавать на сервер, либо сохранять/обновлять при получении, референсным полем является Session ID. Сервер же использует первые 16 байт значения тикета для идентификации набора ключей, которые будут использоваться для проверки его целостности и расшифровки. Таким образом сервер может ротировать значения ключей, продолжая принимать от клиентов тикеты, выданные на старых ключах.

Так выглядит короткий хендшейк с использованием впервые выданного тикета:

2f2058ef082b41c9b915165ef3c971b7.png

где в 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 отсутствует.

Если же тикет был принят сервером, но ключ изменился, то хендшейк выглядит следующим образом:

6a27f907cded4279a27cec13d735e141.png

Значения 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, выглядело так:

f43d2ad904c143b9b370f0a70c238697.png

Здесь 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.

© Habrahabr.ru