[Перевод] Как я сделал переполнение кучи в curl

cc1c003a80b8648718f9dd98ffe118d0.jpg

В связи с выпуском curl 8.4.0 мы публикуем рекомендации по безопасности и все подробности о CVE-2023–38545. Эта проблема является самой серьезной проблемой безопасности, обнаруженной в curl за долгое время. Мы установили для неё ВЫСОКИЙ приоритет.

При этом рекомендации содержат все необходимые подробности. Я решил сказать пару дополнительных слов и более подробно объяснить для всех, кто хочет понять, как эта уязвимость работает, и как это произошло.

Бэкграунд

curl поддерживает SOCKS5 с августа 2002 года.

SOCKS5 — это прокси-протокол. Это достаточно простой протокол настройки сетевого взаимодействия через выделенного «посредника». Например, этот протокол обычно используется при настройке соединения через Tor, а также для доступа в Интернет изнутри организаций и компаний.

SOCKS5 имеет два разных режима разрешения имен хостов. Либо клиент разрешает имя хоста локально и передает пункт назначения в качестве разрешенного адреса, либо клиент передает всё имя хоста прокси-серверу и позволяет самому прокси-серверу разрешить хост удаленно.

В начале 2020 года я решил разобраться с проблемой в curl, которая давно меня ждала: преобразовать функцию, подключающуюся к прокси-серверу SOCKS5, из блокирующего вызова в неблокирующий конечный автомат. Это, например, очень заметно, когда приложение выполняет большое количество параллельных передач, которые идут через SOCKS5.

14 февраля 2020 года я приземлил основной коммит для этого изменения в master. Впервые это улучшение появилось в версии 7.69.0. И, как следствие, также первый релиз, уязвимый для CVE-2023–38545.

Менее разумное решение

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

В верхней части функции я сделал это:

bool socks5_resolve_local =
  (proxytype == CURLPROXY_SOCKS5) ? TRUE : FALSE;

Эта логическая переменная содержит информацию о том, должен ли curl разрешить хост или просто передать имя прокси. Это присвоение выполняется сверху и, следовательно, для каждого вызова во время работы конечного автомата.

Конечный автомат запускается в состоянии INIT, в котором находится основной баг сегодняшней истории. Уязвимость унаследована от функции, существовавшей до того, как она была превращена в конечный автомат.

if(!socks5_resolve_local && hostname_len > 255) {
  socks5_resolve_local = TRUE;
}

SOCKS5 допускает длину поля имени хоста до 255 байт, что означает, что прокси-сервер SOCKS5 не может разрешить более длинное имя хоста. При обнаружении слишком длинного имени хоста, код curl принимает неправильное решение, и вместо этого переключается в режим локального разрешения. Для этой цели локальной переменной устанавливается значение TRUE. (Это условие является остатком кода, добавленного давным-давно. Я думаю, что было совершенно неправильно переключать режим таким образом, поскольку пользователь, запросивший удаленное разрешение, должен придерживаться этого или потерпеть неудачу. Простое переключение вряд ли сработает, даже в «хороших» ситуациях.)

Затем конечный автомат переключает состояние и продолжает работу.

Триггеры этой проблемы

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

Но теперь еще раз взгляните на локальную переменную socks5_resolve_local в верхней части функции. Ему снова присваивается значение в зависимости от режима прокси — измененное значение не запоминается из-за слишком длинного имени хоста. Теперь он снова содержит значение, говорящее, что прокси-сервер должен разрешить имя удаленно. Но имя слишком длинное…

curl создает кадр протокола в буфере памяти и копирует место назначения в этот буфер. Поскольку код ошибочно считает, что он должен передать имя хоста, даже если имя хоста слишком длинное, чтобы поместиться, копия памяти может переполнить выделенный целевой буфер. Конечно, в зависимости от длины имени хоста и размера целевого буфера.

Целевой буфер

Выделенная область памяти, которую curl использует для построения кадра протокола для отправки на прокси, такая же, как и обычный буфер загрузки. Он просто повторно используется для этой цели перед началом передачи. По умолчанию размер буфера загрузки составляет 16 КБ, но по запросу приложения его также можно установить на другой размер. curl устанавливает размер буфера равным 100 КБ. Минимальный допустимый размер — 1024 байта.

Если размер буфера установлен меньше 65541 байт, такое переполнение возможно. Чем меньше размер, тем больше возможное переполнение.

Длина имени хоста

Имя хоста в URL-адресе не имеет ограничения по реальному размеру, но анализатор URL-адресов libcurl отказывается принимать имена более 65535 байт. DNS принимает только имена хостов до 253 байт. Таким образом, легитимное имя более 253 байт является необычным. Настоящее имя длиной более 1024 практически не встречается.

Таким образом, чтобы воспользоваться этой уязвимостью, злоумышленнику необходимо ввести в это уравнение сверхдлинное имя хоста. Чтобы использовать его в атаке. Имя должно быть длиннее целевого буфера, чтобы копия памяти перезаписывала память кучи.

Имя хоста

Поле имени хоста URL-адреса может содержать только подмножество октетов. Диапазон значений байтов просто недействителен и может привести к тому, что анализатор URL-адресов отклонит его. Если libcurl собран для использования библиотеки IDN, она также может отклонять недопустимые имена хостов. Таким образом, эта ошибка может возникнуть только в том случае, если в имени хоста используется правильный набор байтов.

Атака

Злоумышленник, контролирующий HTTPS-сервер, к которому libcurl с помощью клиента обращается через прокси-сервер SOCKS5 (используя режим прокси-резолвера), может заставить его вернуть созданный редирект приложению через ответ HTTP 30x.

Такой 30x редирект будет содержать заголовок Location: в стиле:

Location: https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/

… где имя хоста более 16 КБ и до 64 КБ

Если в клиенте, использующем libcurl, включено автоматическое отслеживание редиректов, а прокси-сервер SOCKS5 «достаточно медленный», чтобы вызвать ошибку локальной переменной, он скопирует созданное имя хоста в слишком маленький выделенный буфер и в соседнюю память кучи.

Затем произошло переполнение буфера кучи.

Исправление

curl не должен переключать режим с удаленного разрешения на локальное из-за слишком длинного имени хоста. Скорее он должен возвращать ошибку, и, начиная с версии curl 8.4.0, так оно и есть.

Теперь у нас есть специальный тестовый кейс для этого сценария.

Титры

Об этой проблеме сообщил, проанализировал и исправил Джей Сатиро.

На сегодняшний день это самая большая выплаченная награда за найденные ошибки в curl: 4660 долларов США (плюс 1165 долларов США проекту curl, согласно политике IBB).

Классический комикс про Дилберта. Исходный URL-адрес, похоже, больше не доступен.

Классический комикс про Дилберта. Исходный URL-адрес, похоже, больше не доступен.

Переписать это?

Да, это семейство уязвимостей было бы невозможно, если бы curl был написан на безопасном для памяти языке вместо C, но портирование curl на другой язык не стоит на повестке дня. Я уверен, что новость об этой уязвимости вызовет новый поток вопросов и призывов к этому, и я могу вздохнуть, закатить глаза и попытаться ответить на этот вопрос еще раз.

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

  • разрешать, использовать и поддерживать больше зависимостей, написанных на языках, безопасных для памяти, и

  • потенциально и постепенно заменять части curl, как при внедрении hyper.

Однако в настоящее время такое развитие происходит едва заметными темпами и с болезненной ясностью показывает проблемы, связанные с этим. В обозримом будущем curl останется написанным на C.

Все, кого это не устраивает, конечно, могут засучить рукава и приступить к работе.

С учетом последних двух CVE, зарегистрированных для curl 8.4.0, совокупное общее количество говорит о том, что 41% уязвимостей безопасности, когда-либо обнаруженных в curl, вероятно, не произошли бы, если бы мы использовали язык, безопасный для памяти. Но также: язык Rust даже не имел возможности практического использования для этой цели в то время, когда мы познакомились, возможно, с первыми 80% проблем, связанных с C.

Душа горит

Читая код сейчас невозможно не увидеть ошибку. Да, мне действительно больно признавать тот факт, что я совершил эту ошибку, не заметив этого, и что ошибка оставалась не обнаруженной в коде в течение 1315 дней. Я прошу прощения. Я всего лишь человек.

Это можно было бы обнаружить с помощью более качественного набора тестов. Мы неоднократно запускали несколько статических анализаторов кода, и ни один из них не обнаружил никаких проблем в этой функции.

Оглядываясь назад, отправка переполнения кучи в коде, установленном в количестве более чем в двадцати миллиардах экземпляров совсем не тот опыт, который я бы посоветовал.

За кулисами

Чтобы узнать, как сообщили об этой уязвимости, и как мы работали над ней до того, как она была обнародована. Проверьте отчет Hackerone (будет опубликован как можно скорее).

© Habrahabr.ru