В связи с выпуском curl 8.4.0 мы публикуем рекомендации по безопасности и все подробности о CVE-2023-38545. Эта проблема является самой серьезной проблемой безопасности, обнаруженной в curl за долгое время. Для неё установили ВЫСОКИЙ приоритет.
Хотя рекомендации содержат все необходимые подробности. Я всё же решил сказать пару дополнительных слов и более подробно объяснить для всех, кто хочет понять, как эта уязвимость работает, и как это произошло.
Бэкграунд
curl поддерживает SOCKS5 с августа 2002 года.
SOCKS5 — это прокси-протокол. Это довольно простой протокол настройки сетевой коммуникации через выделенного «посредника». Протокол, например, обычно используется при настройке связи через Тог, а также для доступа к Интернету внутри организаций и компаний.
SOCKS5 имеет два разных режима разрешения имен хостов. Либо клиент разрешает имя хоста локально и передает конечный адрес как разрешенный, либо клиент передает все имя хоста прокси-серверу и позволяет прокси-серверу самостоятельно разрешить этот хост удалённо.
В начале 2020 года я решил разобраться с вопросом, который давно меня ждал: преобразовать функцию, выполняющую подключение к 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. (Это условие является остатком кода, добавленного давным-давно. Я думаю, что было совершенно неправильно переключать режим таким образом, когда пользователь, запрашивал удалённое разрешение, curl должен был придерживаться этого или потерпеть неудачу. Простое переключение вряд ли сработает, даже в «хороших» ситуациях.)
Затем конечный автомат переключает состояние и продолжает работу.
Триггеры этой проблемы
Если конечный автомат не может продолжить работу, из-за отсутствия дополнительных данных, например, если 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).
Переписать это?
Да, это семейство уязвимостей было бы невозможно, если бы curl был написан на безопасном для памяти языке вместо C, но портирование curl на другой язык не стоит на повестке дня. Я уверен, что новость об этой уязвимости вызовет новый поток вопросов и призывов к этому, и я могу вздохнуть, закатить глаза и попытаться ответить на этот вопрос еще раз.
Единственный подход в этом направлении, который я считаю жизнеспособным и разумным, заключается в следующем:
разрешать, использовать и поддерживать больше зависимостей, написанных на языках, безопасных для памяти, и
потенциально и постепенно заменять части curl, как при внедрении hyper.
Однако в настоящее время такое развитие происходит едва заметными темпами и с болезненной ясностью показывает проблемы, связанные с этим. В обозримом будущем curl останется написанным на C.
Все, кого это не устраивает, конечно, могут засучить рукава и приступить к работе.
С учетом последних двух CVE, зарегистрированных для curl 8.4.0, совокупное общее количество говорит о том, что 41% уязвимостей безопасности, когда-либо обнаруженных в curl, вероятно, не произошли бы, если бы мы использовали язык, безопасный для памяти. Но также: язык Rust даже не имел возможности практического использования для этой цели в то время, когда мы познакомились, возможно, с первыми 80% проблем, связанных с C.
Душа горит
Читая код сейчас, невозможно не заметить ошибку. Да, мне действительно больно признавать тот факт, что я совершил эту ошибку, не заметив этого, и что ошибка оставалась не обнаруженной в течение 1315 дней. Я прошу прощения. Я всего лишь человек.
Этого можно было бы достичь с помощью более качественного набора тестов. Мы неоднократно запускали несколько статических анализаторов кода, и ни один из них не обнаружил никаких проблем в этой функции.
Оглядываясь назад, отправка переполнения кучи в коде, установленном в количестве более чем в двадцать миллиардов экземпляров совсем не тот опыт, который я бы посоветовал.
За кулисами
Проверьте отчет Hackerone, чтобы узнать, как сообщили об этой уязвимости, и как мы работали над ней до того, как она была обнародована.
Комментарии (13)
eee
11.10.2023 16:36+3Ужасный перевод, просто набор слов
DarthPadla
11.10.2023 16:36+1Исправьте ситуацию пожалуйста. Опубликуйте приличный перевод, статья того стоит
bel1k0v Автор
11.10.2023 16:36-12Нет. Стоит. Информация своевременная. Ошибки сообщайте более подробно, я не экстрасенс, чтобы догадаться. Рофлы я не воспринимаю, как полезную информацию - хотите пошутить или посмеяться идите в YouTube. Или проявите уважение и сообщите об ошибках, как это делают нормальные пользователи!
baldr
11.10.2023 16:36+3Ваше право обижаться. Статья полезная - мне сегодня клиент скинул ссылку на краткую новость, из которой было ничерта не понятно. Из вашего перевода плюс оригинал становится гораздо яснее.
Однако, перевод очень дословный и читать реально тяжело. Я понимал смысл половины предложений только через фильтр "как это, скорее всего было в оригинале?". Я понимаю что статья большая и делать литературный перевод для такой новости, может быть, требует слишком много сил, но в итоге получаем то что есть.
От меня плюс за статью и минус за перевод - в итоге не поставил ничего.
bel1k0v Автор
11.10.2023 16:36-4Спасибо. Обиды нет, пытаюсь призвать к другому методу обсуждения. Завтра ещё разок перечитаю, и постараюсь поправить, по-возможности, текст сложный. Глаз замылен.
Мне бы знать, какие именно предложения (их скорее всего 3-4). Перевод как раз не может быть литературный, это техническая статья на 99%. Вы, получается, дубляж хотите, а это уже совершенно другая работа.
Bombus
11.10.2023 16:36-2Говорите, что обиды нет, а кто тогда минусит в карму? Спокойнее надо быть в социуме. Никто на вас не наезжал, и нет ничего плохого в неагрессивной критике или в легких шутках.
vitaly_KF
11.10.2023 16:36+4Статья, несмотря на перевод, интересная.
Вот только… с каких пор пошла мода извиняться за кодинг на C?
Мало ли там языков придумали позже, идут все нафиг! Чего ж они такие умные во времена разработки языка C не наделали своих безопасных языков.
Короче зря он извиняется, бред какой-то.
bel1k0v Автор
11.10.2023 16:36Он разработчик, когда всё работает - не интересно, а вот когда сломается... В статье не только извинения, но и
Все, кого это не устраивает, конечно, могут засучить рукава и приступить к работе.
koreychenko
11.10.2023 16:36-1А где тут атака? Ну, есть у вас сайт, который редиректит бедный Curl на длинное-предлинное доменное имя. Ну погреет клиент немного атмосферы, пока его OOM киллер не прибьёт. В чём профит-то? Или типа как Zip-бомба - сделал гадость и сердцу радость?
mayorovp
11.10.2023 16:36+5Это не утечка памяти, а переполнение буфера. За пределами буфера могут лежать другие объекты, содержимое которых будет перезаписано. Если атакующий сможет выяснить какой именно объект там с некоторой вероятностью лежит — он сможет управлять содержимым этого объекта, а дальше вопрос лишь в том что из этого можно извлечь. Если у того объекта есть любой указатель на функцию (например, таблица виртуальных функций) — это уже RCE.
Tuxman
Очень по-русски написано, особенно про приземление.