
CVE-2024-38063 - 通过 IPv6 远程利用内核
Во всём файле драйвера было сделано ровно одно изменение, которое, как оказалось, и было ошибкой.
Обзор bindiff tcpip.sys до и после установки патча.
Во всём драйвере была изменена только одна функция. Обычно я мог потратить целый день, разбирая 20 и более изменений функций, чтобы понять, на какую из них стоит смотреть, но не в этот раз.

Ipv6pProcessOptions() до установки патча.
Ipv6pProcessOptions() После установки патча.
Была изменена не только одна функция, но и всего одна строка кода.
Функция с чрезвычайно длинным именем Feature_2660322619__private_IsEnabledDeviceUsage_3() иногда добавляется Microsoft для обеспечения частичного отката патчей. Этот вызов проверяет наличие глобального флага или настройки реестра, при установке которых функция возвращает false, в результате чего выполняется оригинальный код вместо исправленной версии.
Причина, по которой Microsoft делает это, заключается в том, что патчи безопасности иногда непреднамеренно ломают что-то, поэтому эта настройка позволяет администратору отменить патч для отдельной уязвимости без удаления всего ежемесячного набора обновлений и существенного ослабления безопасности системы.
Учитывая это, становится ясно, что весь этот патч просто заменяет вызов IppSendErrorList() на IppSendError(), что даёт нам подсказку: проблема связана с каким-то списком. Самый простой дифф патча за всю историю (или я так думал).
Обратный инжиниринг патча для нахождения изменённого кода — это лишь половина задачи (или в данном случае менее 0,1%). Оставшаяся часть процесса включает достаточный обратный инжиниринг кодовой базы, чтобы понять, что вообще происходит, выяснить, какой тип уязвимости был исправлен, как сформировать запрос для достижения целевого кода, и какое состояние приводит к эксплуатабельной ситуации.
Первая часть достаточно проста. Изменение находится в Ipv6pProcessOptions(), что говорит нам о том, что это IPv6 и связано с обработкой опций. Быстрый запрос к RFC даёт точное определение, что такое опция IPv6 и где её можно найти.
Структура заголовка опций назначения из Wikipedia.
Ок, отлично. Похоже, то, что мы ищем, — это заголовок опций назначения, который находится сразу после основного заголовка IPv6. Давайте используем библиотеку Python 'scapy', чтобы сформировать тестовый IPv6-пакет.
Примечание. Для смягчения DDoS-атак с использованием поддельных IP-адресов Windows ограничивает возможность создания сырых IP-пакетов. По этой причине я решил использовать Linux для разработки своего proof-of-concept. Хотя Linux позволяет пользователям создавать и отправлять сырые пакеты канального и сетевого уровней, для запуска Python-скрипта требуются права root.
import sys
import struct
from scapy.all import *
def send_ipv6_option_packet(dest_ip):
ethernet_header = Ether()
ip_header = IPv6(dst=dest_ip)
options_header = IPv6ExtHdrDestOpt()
sendp(ethernet_header / ip_header / options_header)
if len(sys.argv) < 2:
print('Use: python3 script.py <target_ipv6_address>')
exit(-1)
send_ipv6_option_packet(sys.argv[1])
tcpip!Ipv6pProcessOptions и запустив скрипт, я понял, что для достижения уязвимой функции достаточно отправить IPv6-пакет с пустой структурой опций. Затем я попробовал добавить недопустимые опции в структуру, чтобы увидеть, смогу ли я добраться до вызова IppSendErrorList().Краткий обзор кода показал, что почти любое недопустимое форматирование опций может вызвать вызов IppSendErrorList. Поэтому я решил использовать опцию Jumbo Packet с недопустимой длиной (меньше 65535 байт).
options_header = IPv6ExtHdrDestOpt(options=[Jumbo(jumboplen=0x1337)])
Итак, что же на самом деле делает IppSendErrorList()? Ну, код довольно прост.
Вся функция IppSendErrorList.
Код проходит по связному списку и вызывает IppSendError() для каждого элемента списка. Опять звёзды сошлись, и всё пока было просто. Если IppSendErrorList просто вызывает IppSendError для каждого элемента списка, а патч заменяет вызов IppSendErrorList на IppSendError, то проблема возникает, когда IppSendError вызывается для элемента списка, отличного от первого.
Вот тут всё перестало быть очевидным и стало чрезвычайно сложным, хотя я думаю, что большая часть этого была вызвана тем, что одна из моих двух доступных клеток мозга была занята борьбой с тяжёлой формой ковида. Я потратил пару дней на попытки понять части кода, засыпал, а затем забывал, что же я выяснил. Весь процесс потребовал более недели обратного инжиниринга частей tcpip.sys, чтобы разобраться, что происходит. Но пост в блоге Акселя был чрезвычайно полезен.
Изучив функции и структуры, которые Аксель восстановил, а также то, куда они передаются, становится ясно, что единственный аргумент, передаваемый в Ipv6pProcessOptions(), — это та же структура packet_t, описанная в статье. По сути, указатель, передаваемый в Ipv6pProcessOptions и итерируемый IppSendErrorList, представляет собой связный список пакетов.
Итак, я установил точку останова на Ipv6pProcessOptions() и проверил список.

Поле list->Next равно NULL.
Каждый раз, когда срабатывала моя точка останова, список содержал только один пакет. Я потратил больше времени, чем хотелось бы признавать, пытаясь понять, почему и как сделать так, чтобы мой список действительно был списком. Моей первой мыслью была фрагментация IPv6: IPv6 позволяет отправителю разбивать большие пакеты на отдельные меньшие, и было бы логично хранить их вместе в списке.
После обширного обратного инжиниринга я подтвердил свои предположения, хотя список фрагментов не связан с тем, с которым мы имеем дело здесь.
На самом деле я нашёл ответ совершенно случайно. Иногда список заполнялся, но причина была неясна. После долгих блужданий по кругу я понял, что когда срабатывает моя точка останова в ядре, она приостанавливает всё ядро, что приводит к накоплению пакетов в сетевом адаптере. Когда ядро возобновляется, эти пакеты передаются по стеку в tcpip.sys в виде аккуратного списка. Это происходило только в том случае, если пакеты были отправлены, пока ядро было приостановлено, но не обработаны до следующей точки останова.
Скорее всего, это оптимизация производительности: при низкой пропускной способности ядро обрабатывает пакеты по одному, но при больших объёмах пакеты группируются в списки и обрабатываются пакетами. Скорее всего, списки разделяются по таким факторам, как протокол и адрес источника, чтобы ускорить обработку, поэтому наш список должен содержать только отправленные нами IPv6-пакеты.
Теперь, когда мы знаем, что пакеты объединяются в списки при высокой пропускной способности, становится ясно, какой вариант самый простой. Наш DoS PoC, по иронии судьбы, должен будет использовать DoS для запуска DoS-условия. Если мы зальем систему всплесками IPv6-пакетов, мы сможем получить большой список, передаваемый в IppSendErrorList().
Сначала, сколько бы пакетов я ни отправлял, я мог получить список с n > 1 только если приостанавливал ядро. Но... поскольку мы используем Python (невыносимо медленно) на виртуальной машине (дважды невыносимо медленно), нам, вероятно, придётся подкрутить некоторые настройки. Чтобы противодействовать «матрёшке» из ВМ на моей атакующей системе, я решил просто перенастроить целевую ВМ на использование только одного ядра ЦП.

Отлично! Теперь список пакетов — это список с множеством записей!
IppSendError() и в чём заключается проблема.После некоторого обширного обратного инжиниринга стало гораздо понятнее, что делает IppSendError. В обычных условиях он просто отключает пакет, устанавливая net_buffer_list->Status в 0xC000021B (STATUS_DATA_NOT_ACCEPTED). Затем он отправляет обратно отправителю ICMP-ошибку, содержащую информацию о некорректном пакете.
Две релевантные части IppSendError.
Первым делом я проверил, есть ли в tcpip.sys функции, которые игнорируют значение net_buffer_list->Status. Это привело бы к тому, что драйвер обрабатывал бы пакеты в неопределённых или неожиданных состояниях, что, надеюсь, привело бы к условию эксплуатации.

Основной цикл, отвечающий за обработку пакетов.
Поскольку цикл, отвечающий за вызов всех функций разбора, обёрнут в проверку ошибок (а значит, мы не можем никуда попасть после установки кода ошибки), я решил, что это не та кроличья нора, в которую стоит углубляться. Вместо этого я решил вернуться к IppSendError и посмотреть, есть ли какие-либо пути кода, которые изменяют состояние пакета до установки кода ошибки, что может привести к состоянию гонки.
После ещё большего количества обратного инжиниринга я нашёл следующий код в самом низу IppSendError.

Путь кода в IppSendError, устанавливающий packet_size в ноль.
Когда IppSendErrorList (а следовательно, и IppSendError) вызывается с аргументом always_send_icmp, установленным в true, он, по-видимому, пытается отправить ICMP-ошибку для каждого пакета в списке.
Затем, по причинам, известным, наверное, только богу, он попадает в блок кода, где поле packet->packet_size устанавливается в ноль.
Чтобы установить always_send_icmp в true, достаточно вызвать определённую ошибку в обработке заголовка опций, установив значение 'Option Type' в любое число больше 0x80.
def build_malicious_option(next_header, header_length, option_type, option_length):
dest_options_header = 60
options_header = struct.pack('BBBB', next_header, header_length, option_type, option_length) + b'1337'
return Ether(dst=mac_addr) / IPv6(dst=ip_addr, nh=dest_options_header) / raw(options_header)
packet = build_malicious_option(next_header=59, header_length=0, option_type=0x81, option_length=0)
sendp(packet)
Но разве установка packet_size в ноль не сломает парсер?

Фрагмент из основного цикла, отвечающего за обработку пакетов.
Обработчик пакетов просто вызывает функцию из VTable на основе значения packet->next_header, которое остаётся неизменным с момента его установки во время предварительного разбора. Это позволяет продолжить обработку пакета и даже даёт нам контроль над тем, какая обработка будет выполняться.
Поскольку значение packet->next_header берётся из поля 'Next Header' IPv6-пакета, мы можем установить его в любое допустимое значение заголовка IPv6, и цикл вызовет соответствующий парсер. Это даёт нам много потенциальной поверхности атаки.

Формат IPv6-пакета.
Всё, что осталось сделать, — найти достижимую часть парсера IPv6, которая делает что-то глупое с полем packet_size.

Эх... так близко, но так далеко
У нас действительно есть уязвимость, но это не RCE.
По сути, на большинстве ЦП регистры являются циклическими. Если увеличивать регистр сверх его максимально возможного значения, он зацикливается обратно на ноль. Аналогично, если уменьшать его ниже минимального значения, он зацикливается на максимальное возможное значение. Это называется целочисленным переполнением и целочисленным исчерпанием соответственно. Такое поведение немного отличается для знаковых целых чисел, но мы имеем дело не с ними.
Первая строка, fragment_size = LOWORD(packet->packet_size) - 0x30, состоит из следующего ASM-кода:

ASM-код, вычисляющий размер фрагмента.
AX — это младшие 16 бит регистра EAX. Хотя регистр EAX 32-битный, AX работает как отдельный 16-битный регистр, поэтому любые переполнения или исчерпания ограничиваются AX и не влияют на остальную часть EAX. Это невероятно удобно, потому что исчерпание регистра EAX привело бы к значению 4 миллиарда, что, скорее всего, привело бы к попытке выделить 4 ГБ памяти, которая, вероятно, завершилась бы неудачей.
Поскольку значение packet->packet_size равно нулю, этот код устанавливает ax в ноль, а затем вычитает из него 0x30.
В нормальных условиях заголовок пакета составляет 0x30 байт, поэтому packet_size - 0x30 — это размер данных фрагмента.
В нашем случае packet->packet_size равен 0, поэтому вычитание даже 1 из него заставит регистр зациклиться на максимально возможное значение 16-битного целого (0xFFFF). Поскольку мы вычитаем 0x30, значение AX выйдет за пределы и станет MAX_VALUE - 0x2F, или 0xFFD0, что равно 65 488.
К сожалению, поскольку одно и то же вычисление используется как для выделения памяти, так и для копирования данных, мы не получаем переполнения буфера. Я полагаю, что также выполняет проверку границ исходного буфера, так что мы не получаем даже чтения за пределами границ. Однако мы не уходим с пустыми руками.
Фрагменты IPv6 остаются в памяти до тех пор, пока не наступит одно из трёх условий:
Ipv6pReassemblyTimeout() вызывается при условии 3, так что давайте рассмотрим, как это можно использовать.Ipv6pReassemblyTimeout() вызывается при условии 3, так что давайте рассмотрим, как это можно использовать.

Именно то, что нам нужно!

Код сборки, отвечающий за вычисление размера выделения.
Как видите, первая часть вычисления (fragment_list->net_buffer_length + reassembly->packet_length + 8) выполняется с использованием 16-битного регистра DX.
Если вы помните из ранее, мы вызвали исчерпание reassembly->packet_length до 0xFFD0. Таким образом, регистр DX после добавления 8 байт равен 0xFFD8. Если fragment_list->net_buffer_length больше 0x27 (39 байт), DX переполнится и сбросится на ноль.
fragment_list->net_buffer_length должен быть около 0x38 байт, так что это приведет к переполнению регистра DX до 8. После добавления 0x28 байт мы получим выделение памяти всего в 48 байт.
Поскольку последующий вызов memmove() просто использует исходное значение reassembly->packet_length для размера, это приведёт к копированию 65 488 байт из reassembly->payload в 30-байтовый буфер. -Отличным бонусом является то, что большая часть скопированных данных поступает из полезной нагрузки фрагмента, которую мы контролируем и которая может быть произвольными данными любого формата, так что мы получаем хорошее, довольно контролируемое переполнение буфера кучи ядра.
Чтобы иметь шанс вызвать уязвимость, нам нужно, чтобы после неверно сформированного пакета опций в связном списке находился один или несколько фрагментированных пакетов в момент вызова IppSendErrorList. Однако, согласно моим тестам, это, по-видимому, не гарантирует эксплуатацию. Я полагаю, что должны быть выполнены и некоторые другие условия. Я подозреваю, но не подтвердил, что код синхронизации в IppSendError означает, что мы также должны выиграть состояние гонки.
RtlCopyMdlToBuffer()Поскольку ExAllocatePoolWithTagPriority() не обнуляет выделенную память, а RtlCopyMdlToBuffer() копирует только фактическое количество доступных данных, мы получаем около 65 кб неинициализированной памяти ядра. Поскольку адреса памяти перерабатываются после освобождения, буфер, скорее всего, заполнен тем, что хранилось по этому адресу до перераспределения. Если мы сможем использовать фрагментацию для создания пакета, который будет отправлен обратно нам, например, ICMP Echo-запроса, мы потенциально сможем утечь случайную память ядра, что приведёт к обходу ASLR.
Вдобавок ко всему, код также устанавливает reassembly->fragment_size в переполненное 16-битное целое (65 488), так что теперь у нас есть две отдельные переменные, которые потенциально можно использовать для вызова переполнения буфера.
Решение (или, по крайней мере, одно из них) — Ipv6pReassemblyTimeout(). Хотя мы не можем вызвать переполнение при начальной обработке фрагментов, мы, по-видимому, можем это сделать во время очистки.