Гостевая статья Как обманчивое утверждение привело к критической уязвимости ядра WINDOWS

В обновлении программного обеспечения, выпущенном в ноябре 2019 года, незначительное изменение кода в драйвере ядра Windows привело win32kfull.sys к значительной уязвимости. Изменение кода должно быть безвредным. На первый взгляд, изменение заключалось только в вставке одного вызова функции типа assert для защиты от определенных недопустимых данных в параметре. В этой статье мы рассмотрим соответствующую функцию и посмотрим, что пошло не так. Об этой ошибке сообщили нам anch0vy @ theori и kkokkokye @ theori, и она была исправлена Microsoft в феврале 2020 года как .

Понимание функции

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

Функция есть win32kfull.sys!NtUserResolveDesktopForWOW. Префикс Ntуказывает, что эта функция является членом того, что иногда называют «Windows API Native», что означает, что это функция ядра верхнего уровня, доступная для вызова из пользовательского режима с помощью syscall инструкции. Для наших целей нет необходимости понимать точное назначение NtUserResolveDesktopForWOWAPI (которое фактически недокументировано). Скорее, мы должны знать, что он NtUserResolveDesktopForWOW вызывается из пользовательского режима и что фактическая реализация находится в функции нижнего уровня с именем win32kfull!xxxResolveDesktopForWOW. Функция NtUserResolveDesktopForWOWделает очень мало сама по себе. Его основная задача - безопасно обмениваться данными параметров и результатов между пользовательским режимом и режимом ядра.

Сигнатура этой функции выглядит следующим образом:

NTSTATUS NtUserResolveDesktopForWOW(_UNICODE_STRING *pStr)

Единственный параметр типа _UNICODE_STRING* является параметром in-out. Вызывающая сторона передает указатель на _UNICODE_STRING структуру в пользовательской памяти, изначально заполненную данными, которые служат входными данными для функции. Перед возвратом NtUserResolveDesktopForWOW перезаписывает эту структуру пользовательского режима _UNICODE_STRING новыми строковыми данными, представляющими результат.

_UNICODE_STRING Структура определена следующим образом :

Picture1 (1).png



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

Как уже упоминалось выше, основная цель NtUserResolveDesktopForWOW заключается в безопасном обмене данными при вызове xxxResolveDesktopForWOW. В NtUserResolveDesktopForWOW функции выполняет следующие шаги, все из которых имеют решающее значение для безопасности:

1: он принимает параметр типа _UNICODE_STRING* из пользовательского режима и проверяет, является ли он указателем на адрес пользовательского режима, а не адресом режима ядра. Если он указывает на адрес режима ядра, он генерирует исключение.

2: Копирует все поля _UNICODE_STRING локальных переменных, недоступных в режиме пользователя.

3: Чтение из этих локальных переменных проверяет целостность _UNICODE_STRING. В частности, он проверяет, что Lengthоно не больше MaximumLengthи Bufferсуществует полностью в памяти пользовательского режима. Если один из этих тестов не пройден, он генерирует исключение.

4: Опять же, используя значения в локальных переменных, он создает новое, _UNICODE_STRINGкоторое полностью живет в памяти режима ядра и указывает на новую копию режима ядра исходного буфера. Мы называем эту новую структуру kernelModeString.

5: Это переходит kernelModeString к основной функции xxxResolveDesktopForWOW. После успешного завершения xxxResolveDesktopForWOW помещает свой результат в kernelModeString.

6: Наконец, если xxxResolveDesktopForWOW он успешно завершен, он копирует строковый результат xxxResolveDesktopForWOW в новый буфер пользовательского режима и перезаписывает исходную _UNICODE_STRING структуру, чтобы указывать на новый буфер.

Зачем нужен этот сложный танец? Прежде всего, опасность, которую он должен оберегать, заключается в том, что процесс пользовательского режима может передавать указатель на память ядра либо через Bufferполе, либо как сам pStr параметр. В любом случае, xxxResolveDesktopForWOW будет действовать на данные, прочитанные из памяти ядра. В этом случае, наблюдая результат, код пользовательского режима может найти подсказки о том, что существует по указанным адресам режима ядра. Это будет утечка информации из режима ядра с высокими привилегиями в режим пользователя с низкими привилегиями. Кроме того, если pStr сам адрес является адресом режима ядра, то может произойти повреждение памяти ядра, когда результат xxxResolveDesktopForWOW записывается обратно в память, указанную параметром pStr.

Чтобы должным образом защититься от этого, недостаточно просто вставить инструкции для проверки пользовательского режима _UNICODE_STRING. Рассмотрим следующий сценарий:

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

Это тип уязвимости, связанной с временем (TOCTOU), и в таком контексте, когда два фрагмента кода, работающие с различными уровнями привилегий, обращаются к общей области памяти, она называется «Двойная выборка». Это относится к двум выборкам, которые код ядра выполняет в сценарии выше. Первая выборка извлекает действительные данные, но к тому времени, когда происходит вторая выборка, данные были отравлены.

Устранение уязвимостей двойной выборки состоит в том, чтобы гарантировать, что все данные, собранные ядром из пользовательского режима, выбираются ровно один раз и копируются в состояние режима ядра, которое не может быть изменено в пользовательском режиме. Это причина для шагов 2 и 4 в операции NtUserResolveDesktopForWOW, которые копируют _UNICODE_STRING в пространство ядра. Обратите внимание, что проверка Bufferуказателя откладывается до завершения шага 2, поэтому проверка данных может быть сформирована только после того, как они были скопированы в защищенное хранилище.

NtUserResolveDesktopForWOW даже копирует сам строковый буфер в память ядра, что является единственным действительно безопасным способом устранения всех возможных проблем, связанных с возможной двойной выборкой. При выделении буфера режима ядра для хранения строковых данных он выделяет буфер того же размера, что и буфер режима пользователя, как указано MaximumLength. Затем он копирует фактические байты строки. Чтобы эта операция была безопасной, она должна Lengthбыть не более MaximumLength. Эта проверка также включена в шаг 3 выше.

Кстати, в свете всего вышесказанного, я бы скорее сказал, что сигнатура функции:

NTSTATUS NtUserResolveDesktopForWOW(volatile _UNICODE_STRING *pStr)

volatileКлючевое слово предупреждает компилятор , что внешний код может изменять _UNICODE_STRING структуру в любое время. Без volatileэтого возможно, что сам компилятор C / C ++ мог бы вводить двойные выборки, которых нет в исходном коде. Это сказка в другой раз.

Уязвимость

Уязвимость обнаружена при проверке шага 3. До неудачного обновления программного обеспечения в ноябре 2019 года код проверки выглядел следующим образом:

Picture2 (1).png



MmUserProbeAddressявляется глобальной переменной, которая содержит адрес, разграничивающий пространство пользователя от пространства ядра. Сравнения с этим значением используются для определения того, указывает ли адрес пространство пользователя или пространство ядра.

Код *(_BYTE *)MmUserProbeAddress = 0используется, чтобы вызвать исключение, так как этот адрес никогда не доступен для записи.

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

Picture3 (1).png


Обратите внимание, что length_ecxэто просто имя, которое я дал локальной переменной, в которую Lengthкопируется поле. Хранением для этой локальной переменной является ecxрегистр и, следовательно, имя.

Как вы можете видеть, код теперь выполняет одну дополнительную проверку достоверности перед остальными: он гарантирует, что length_ecx & 1 равен 0, то есть он гарантирует, что указанное число Length является четным. Было бы недействительным, Length чтобы быть нечетным числом. Это связано с тем, что Lengthуказывает число байтов, занимаемых строкой, которое всегда должно быть четным, поскольку каждый символ Unicode в строке представлен 2-байтовой последовательностью. Таким образом, перед тем, как перейти к остальным проверкам, он обеспечивает Length четность, и если эта проверка не проходит, то нормальная обработка останавливается и вместо этого происходит утверждение.

Или это?

Здесь проблема. Оказывается, функция MicrosoftTelemetryAssertTriggeredNoArgsKM вообще не является утверждением! В отличие от утверждения, которое выдает исключение, MicrosoftTelemetryAssertTriggeredNoArgsKM генерирует только некоторые данные телеметрии для отправки обратно в Microsoft, а затем возвращает вызывающему. Весьма прискорбно, что слово «Assert» появляется в названии функции, и на самом деле имя функции, похоже, обмануло разработчика ядра в Microsoft, который добавил в проверку length_ecx. Похоже, что у разработчика сложилось впечатление, что вызов MicrosoftTelemetryAssertTriggeredNoArgsKM прервет выполнение текущей функции, так что оставшиеся проверки могут быть безопасно перенесены в elseпредложение. На самом деле, то, что происходит, если Length нечетное, выглядит следующим образом:MicrosoftTelemetryAssertTriggeredNoArgsKM вызывается, а затем управление возвращается к текущей функции. Остальные проверки пропускаются, потому что они есть в elseпредложении. Это означает, что, задав нечетное значение для Length, мы можем пропустить все оставшиеся проверки.

Насколько это плохо? Очень плохо, как оказалось. Напомним, что в попытке обеспечить максимальную безопасность NtUserResolveDesktopForWOW копирует сами строковые данные в буфер ядра. Он выделяет буфер ядра того же размера, что и исходный пользовательский буфер MaximumLength. Затем он копирует байты строки в соответствии с числом, указанным в Length. Поэтому, чтобы избежать переполнения буфера, необходимо было добавить проверку, чтобы убедиться, что Lengthона не больше MaximumLength. Если мы можем пропустить эту проверку, мы получим прямое переполнение буфера в памяти ядра.

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

Если вы хотите попробовать это сами, код PoC - это не что иное, как следующее:

Picture4 (1).png



Это позволит выделить буфер пула ядра размером 2 и попытаться скопировать 0xffffв него байты из пользовательской памяти. Возможно, вы захотите запустить это с включенным Special Pool для win32kfull.sysобеспечения предсказуемого сбоя.

Вывод

Microsoft исправила эту уязвимость незамедлительно в феврале 2020 года. Суть исправления заключается в том, что код теперь явно вызывает исключение после вызова MicrosoftTelemetryAssertTriggeredNoArgsKM. Это сделано, написав *MmUserProbeAddress. Хотя Microsoft перечисляет это как изменение «графического компонента Windows», ссылка на win32kfull.sysдрайвер ядра, который играет ключевую роль в рендеринге графики.

Мы хотели бы поблагодарить anch0vy @ theori и kkokkokye @ theori за сообщение об этой ошибке в ZDI. Мы, безусловно, надеемся увидеть больше исследований от них в будущем.

Источник:
 
Мы в соцсетях:

Обучение наступательной кибербезопасности в игровой форме. Начать игру!