
Этот документ и репозиторий представляют собой разбор CVE−2022-3602, проблемы переполнения буфера в реализации punycode в OpenSSL. Это «анти-PoC» (проблема, судя по всему, не эксплуатируема), предназначенный для тех, кто поддерживает собственные сборки OpenSSL, и для разработчиков компиляторов.
В том же релизе есть отдельная CVE, CVE-2022-3786, которая также приводит к переполнению буфера, но в этом случае атакующий не может контролировать содержимое. Здесь нет воспроизведения для этой проблемы, но она может привести к отказу в обслуживании из-за краха.
Краши и переполнения буфера никогда не бывают хорошими, и если вы используете OpenSSL 3.0.x, благоразумно обновиться как можно скорее.
Пожалуйста, сообщайте о любых ошибках или пропусках через GitHub issues или pull-requests.
В функции ossl_punycode_decode есть ошибка off-by-one при обработке декодирования punycode, которая приводит к переполнению в 4 байта. Эта проблема возникает только тогда, когда OpenSSL обрабатывает цепочку сертификатов, и требует двух условий. Во-первых, сертификат CA или промежуточного удостоверяющего центра в цепочке должен содержать поле name-constraint, использующее punycode.
nameConstraints = permitted;email:xn-maccrthaigh-n7a.com
Во-вторых, конечный сертификат должен содержать поле otherName в SubjectAlternateName (SAN), указывающее строку SmtpUTF8Mailbox.
otherName = 1.3.6.1.5.5.7.8.9;UTF8:[email protected]
При срабатывании punycode из поля nameConstraints, но не punycode из поля otherName, будет обработан уязвимым парсером punycode в OpenSSL.
Дэвид Бенджамин (David Benjamin) и Мэтт Касвелл (Matt Caswell) установили, что проверка nameConstraint происходит после обычной проверки цепочки сертификатов и проверки подписи. Для большинства приложений это означает, что проблема не может быть вызвана самоподписанным сертификатом или недействительной цепочкой.
Обратите внимание, что приложения s_client и s_server из openssl предназначены для отладки и не прекращают обработку, когда цепочка недействительна.
Доверенный CA или промежуточный сертификат должен содержать вредоносную полезную нагрузку, а также должен подписать конечный сертификат, который вызывает проблему.
Могут быть среды, в которых недоверенные стороны являются CA или промежуточными удостоверяющими центрами, например, хостинг-сервис, поддерживающий частные CA, предоставленные клиентом, но это не распространено.
Для многих приложений ответ будет «нет» из-за того, как компилятор разместил стек, и из-за наличия других защит, таких как стековые канарейки / стековые куки, выравнивание, PIE, FORTIFY_SOURCE.
Проблема действительно приводит к переполнению 32 бит в стеке. Этого недостаточно для непосредственного выполнения шелл-кода, но может быть достаточно, чтобы изменить поток управления приложением. Например, переход на шелл-код, встроенный в цепочку сертификатов X509, может быть возможен, если эти данные также скопированы в стек в исполняемую область.
На всех протестированных мной платформах Linux переполнение происходит в выравнивание и безвредно. Теоретически компилятор может разместить переменные так, что переполнение произойдет в одну из других переменных функции ossl_a2ulabel.
В зависимости от инлайнинга полный список присутствующих переменных:
outptr, inptr, size, result, tmpptr, delta, seed, utfsize
и ни одна из них, как мне кажется, не дает очевидного пути к повышению привилегий или интересному контролю.
Я приложил tarball с инструментами, которые можно использовать для создания воспроизведений и переполнений с максимально возможным контролем над всеми четырьмя байтами. Эталонная строка воспроизведения (xn--ww90271...aaaa) переполняет четыре байта значениями 0xFF 0x0F 0x0F 0x0F. Если это не приводит к краху приложения, возможно (вероятно?), это приложение не уязвимо.
Скрипт run-poc можно использовать для генерации вредоносной цепочки сертификатов. Вредоносный сертификат CA генерируется из ca.cnf, а вызывающий проблему конечный сертификат — из leaf.cnf.
Сертификат CA использует следующую эталонную полезную нагрузку:
xn--ww902716aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Python-скрипт можно использовать для генерации других punycode-строк для других полезных нагрузок.
При выполнении run-poc будет запущен клиент и сервер openssl, и будет предпринята попытка эксплуатировать проблему десять раз.
Уязвимый OpenSSL, скорее всего, упадет. Это не значит, что версия OpenSSL уязвима к RCE, поскольку стековые канарейки и защита стековых куки также обычно вызывают (более безопасный) крах приложения. Также обратите внимание, что это никак не меняет серьезность другой CVE в том же релизе.
Удивительно тонко получить почти полный контроль над всеми четырьмя байтами переполнения, и для этого требуется эксплуатировать декодер punycode в OpenSSL с помощью нестандартного / недействительного punycode. В приложенном tarball есть скрипт, который может сконструировать строку, учитывающую эту тонкость. Ниже приводится объяснение того, как это работает.
Проблема безопасности в ossl_punycode_decode()
int ossl_punycode_decode(const char *pEncoded, const size_t enc_len,
unsigned int *pDecoded, unsigned int *pout_length)
ossl_punycode_decode вызывается из ossl_a2ulabel. Буфер pEncoded — это буфер более или менее произвольного размера, поступающий из цепочки сертификатов X509. Это часть, которая идет после любого xn-- в поле nameConstraint. См. [reproduction] для того, как воспроизвести такую цепочку сертификатов.
pDecoded — это массив unsigned int размером LABEL_BUF_SIZE. LABEL_BUF_SIZE равен 512, и на большинстве платформ unsigned int имеет ширину 4 байта. Таким образом, на большинстве платформ pDecoded имеет длину 2048 байт.
Внутри ossl_punycode_decode() суть проблемы — следующая неверная проверка длины:
if (written_out > max_out)
max_out соответствует *pout_length, который всегда равен 512. А written_out отслеживает, сколько unsigned int было записано в pDecoded. Поскольку written_out увеличивается позже, только после записи, эта ошибочная проверка позволяет записать 513 unsigned int в pDecoded. Конечный результат выглядит примерно так ...
pDecoded = [ ... , 'X , 'Y' , 'Z' ] 'P'
// Indices 509 510 511
Здесь, по соглашению C, индексы начинаются с нуля, поэтому слот номер 511 — это 512-й элемент массива. 'P' — это четырехбайтовая полезная нагрузка, которая была записана за пределами выделенного в стеке пространства для буфера buf в ossl_a2ulabel(), на который указывает pDecoded.
Четыре байта — это небольшое переполнение, которого недостаточно для NOP-слайда или непосредственного выполнения шелл-кода, но достаточно, чтобы изменить поток управления приложением. Например, переход на шелл-код, встроенный в цепочку сертификатов x509, может быть возможен, в зависимости от того, как эти данные (или скопированные фрагменты этих данных) хранятся и является ли эта память исполняемой. Однако для потенциального атакующего есть еще больше трудностей.
Во-первых, выравнивание и размещение стека компилятором или такие защиты, как стековые канарейки, могут сделать любую эксплуатацию полностью невозможной.
Во-вторых, есть только один путь к ossl_punycode_decode(), и этот путь использует буфер в стеке. Это делает маловероятным использование проблемы для одновременных четырехбайтовых переполнений в разных областях памяти.
Punycode-строки в основном имеют две формы. Одна — xn--c1yn36f (點看), другая — xn--maccrthaigh-n7a (maccárthaigh). Часть, которая идет после последнего разделителя -, представляет собой 36-ричное bootstring-кодирование любых кодовых точек Unicode, которые не являются обычным базовым ascii, вместе с позицией в строке для их вставки. Важно сейчас то, что процесс декодирования в ossl_punycode_decode() производит два значения. Одно — 'n', которое является unsigned int значением кодовой точки для вставки, а другое — 'i', которое является позицией в буфере для ее вставки.
Запись может происходить двумя разными способами. Если i находится где-то в середине строки, то есть memmove(), который сначала «освобождает место», копируя все вправо на один слот:
memmove(pDecoded + i + 1, pDecoded + i,
(written_out - i) * sizeof *pDecoded);
а затем записывает n в только что освобожденное место:
pDecoded[i] = n;
если i находится в конце строки, то memmove() не имеет эффекта, потому что последний параметр будет равен 0. Другая строка становится простым добавлением в конец.
Теперь мы рассмотрим три различных способа получить полезную нагрузку 'P' в позицию переполнения и почему возникают ограничения.
Самый простой способ вызвать переполнение — создать punycode-строку, содержащую 511 ascii-символов и два не-ascii символа. Punycode-кодирование строки длиной 513 символов, такой как "ÁÁAAAAAAAA...AAA", подойдет. В этом случае произойдет следующее: когда written_out равно 510, у нас будет буфер, расположенный так ...
pDecoded = [ 'A' , 'A' , ... , 'A' , 'A', ]
// Indices 0 1 ... 509 510 511
это просто базовые ascii-символы, которые были скопированы. Затем мы разбираем punycode bootstring и вставляем 'Á' в позицию 0. Хотя это может быть любая позиция от 0 до 511 включительно.
pDecoded = [ 'Á' , 'A' , ... , 'A' , 'A', 'A' ]
// Indices 0 1 ... 509 510 511
затем мы повторяем это:
pDecoded = [ 'Á' , 'Á' , ... , 'A' , 'A', 'A' ] 'A'
// Indices 0 1 ... 509 510 511 512
это приведет к тому, что обычный ascii 'A' переполнится, поскольку он «сдвигается». Четырехбайтовая полезная нагрузка в этом случае становится 0x00 0x00 0x00 0x41. Как мы увидим, из-за того, как работает punycode, это единственный способ выразить любое значение с последним байтом в диапазоне ascii.
Нам нужно использовать два не-ascii символа, потому что есть правильная проверка границ для количества базовых символов, поэтому их должно быть меньше 512.
Дополнительное ограничение, заключающееся в том, что значение последнего байта не может быть 46, возникает потому, что ossl_punycode_decode() вызывается для части строки, которая предшествует литеральному символу .. Punycode предназначен для доменных меток, в которых не может быть точек.
Следующий самый простой способ вызвать переполнение — создать строку из 513 символов с не-ascii символом в самом конце. Что-то вроде "AAAAAAAAAA...AAÁ". В этом случае для наших последних двух шагов мы будем иметь:
pDecoded = [ 'A' , 'A' , ... , 'A' , 'A' ] // Indices 0 1 ... 510 511
и
pDecoded = [ 'A' , 'A' , ... , 'A' , 'A' ] 'Á'
// Indices 0 1 ... 510 511
не-ascii символ пойдет прямо в позицию переполнения. Парсер punycode в OpenSSL не требует, чтобы значение переполнения здесь было действительно допустимым символом Unicode. Это более или менее бинарный процесс декодирования. Но нюансы декодирования punycode означают, что метод 2 не так гибок, как может показаться на первый взгляд.
В punycode значения n и i оба кодируются как одно целое переменной длины, которое затем кодируется в ascii с использованием base36. Может показаться невозможным закодировать два несвязанных числа как одно целое, но хитрый трюк punycode заключается в использовании длины строки (на данный момент) как скрытого поля.
Например, предположим, у нас есть punycode-строка с 4 базовыми символами и одним не базовым, например AAÁAA. Сначала она будет представлена только базовыми символами ... AAAA. Значение Unicode для 'Á' равно 225, а его позиция в строке — 2. Фокус в том, чтобы умножить значение на длину плюс один, а затем добавить позицию. Таким образом, получается ((225 * (4 +1)) + 2), что равно 1127, и именно так это кодируется (в base36 переменной длины).
Для декодирования нужно идти в обратном направлении. 1127 / 5 равно 225, а 1127 % 5 равно 2. Вот так из одного числа получаются два. Но заметьте, что чем длиннее становится строка, тем более ограниченным становится максимальное значение, иначе кратное не поместится в unsigned int. В общем случае, если строка имеет длину M символов, то вы теряете log M бит ширины значения.
К тому времени, когда вы обрабатываете 512-е целое, вы теряете 9 бит ширины. Используя метод 2, самое высокое значение, которое, казалось бы, 32-битная полезная нагрузка может иметь, на самом деле равно 2^23. Даже не полных три байта. Метод 2 неоптимален.
Чтобы вернуть контроль над 4 байтами, самый эффективный способ — повторять символ полезной нагрузки снова и снова. Пока я упустил две другие важные детали того, как обрабатывается punycode.
Первая деталь: не-ascii символы кодируются не в порядке строки, а в порядке возрастания значения. Строка "ÉÁ" в итоге будет закодирована как "Á на позиции 1, É на позиции 0", потому что Á имеет меньшее значение (225), чем É (233).
Вторая деталь: не-ascii символы кодируются не как их буквальные значения, а как дельта относительно последнего декодированного значения. Поскольку у первого значения нет предыдущего значения, относительно которого можно было бы вычислить дельту, есть жестко заданная начальная точка 128.
Эти маленькие нюансы делают punycode очень эффективным по пространству, но также означают, что не-ascii символ просто не может быть декодирован в значение меньше 128. Наименьшая дельта равна 0, и нет способа выразить отрицательную дельту. Поэтому, если вам нужно число меньше 128, вы должны использовать метод 1.
Это также означает, что лучшая стратегия для максимального контроля над полезной нагрузкой — сделать полезную нагрузку единственным значением во всей строке, поскольку так мы получаем полную ширину для работы с ее места на 0-й позиции в кодировании. Кодируемая строка в итоге выглядит так:
[ 'P', 'P', ... 'P', 'P', 'P' ]
0 1 510 511 512
которая будет декодирована OpenSSL как ...
pDecoded = [ 'P', 'P', ... 'P', 'P' ] 'P'
0 1 510 511 512
с P в позиции переполнения и способным представлять любое значение от 128 до (2^32 - 1).
Все это требует нестандартного punycode-энкодера, и я включил скрипт, который может создать полезную нагрузку, используя метод 1 или метод 3 по мере необходимости.
Помимо обновления OpenSSL, есть ли другие меры смягчения?
В большинстве сред цепочки сертификатов передаются в открытом виде, и вредоносная цепочка может быть заблокирована отклонением TCP-соединений, которые содержат DER-закодированный NID 1.3.6.1.5.5.7.8.9 в поле OtherName в SubjectAlternateName.
К сожалению, это поле может быть произвольно разбито между двумя или более пакетами, и для блокировки действительно нужен какой-то механизм сопоставления с образцом с сохранением состояния. Сертификаты также могут быть сжаты, но OpenSSL 3.0.x в настоящее время не поддерживает сжатие сертификатов.
Кроме того, в TLS1.3 цепочки клиентских сертификатов шифруются в канале, а более ранние версии TLS поддерживают шифрованные цепочки сертификатов при повторном согласовании существующего соединения. Иногда это используется для аутентификации по сертификату, инициированной сервером. В таких случаях сетевой фильтр не будет эффективен.
Как я могу узнать, использую ли я openssl 3 в статически слинкованном двоичном файле?
readelf -a [binary] | grep -i ossl_punycode_decode
будет искать уязвимую функцию в статически слинкованном двоичном файле. Только OpenSSL >= 3.0 содержит эту функцию.