Некоторое время назад коллеги стали получать от пользователей жалобы на то, что иногда при использовании Поиска и Яндекс.Браузера они видят ошибку 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.

Комментарии (9)


  1. zorgrhrd
    29.10.2015 17:27
    +16

    Люблю когда дебаг одного, заканчивается исправлением ошибки другого.


    1. Stalker_RED
      29.10.2015 17:50
      +19

      Особенно когда есть возможность «исправить ошибку другого», а не как обычно — нашли ошибку отрепортили, а исправления не видать. И приходится городить костыли на своей стороне, ага.


  1. nmk2002
    29.10.2015 19:01
    -1

    Жаль, что google теперь использует свой форк openssl. От такого гиганта помощь в развитии openssl была бы не лишней.


    1. kyprizel
      29.10.2015 19:03

      На самом деле они продолжают поддерживать OpenSSL, часть команды проекта работает в Google.
      подробнее:
      www.imperialviolet.org/2015/10/17/boringssl.html


      1. nmk2002
        29.10.2015 19:08
        +1

        Спасибо за ссылку. Это хорошие новости. Я думал, что они полностью сконцентрируются на своем продукте.
        Дальнейшее использование openssl было бы значимым вкладом в проект.


        1. Ununtrium
          30.10.2015 13:30

          За работу с таким кодом (OpenSSL) надо досрочный выход на пенсию, как в особо опасных для здоровья профессиях.


          1. nmk2002
            30.10.2015 13:35

            Слышал, что сейчас со стилем программирования должно быть лучше.


  1. ToSHiC
    29.10.2015 19:42
    +5

    О да, я помню этот вечер дебага :)


  1. Makc666
    02.11.2015 14:10
    +2

    Дико извиняюсь, но никак не получается добиться адекватного ответа от поддержки Яндекс [1509281351*******].

    1. httpS//www.yandex.ru/
    2. Любой поиск.
    3. В результатах клики «onmousedown» ведут на: http://yandex.ru/clck/jsredir?from=yandex.ru%3Bsearch%2F%3Bweb%3B%3B&text=&etext=
    Почему нет HTTPS?

    При этом, если вручную, то успешно работает: httpS://yandex.ru/clck/jsredir?from=yandex.ru%3Bsearch%2F%3Bweb%3B%3B&text=&etext=