Анатомия доступа: объекты устройств Windows с точки зрения безопасности

Источник: Atos•

Анатомия доступа: объекты устройств Windows с точки зрения безопасности

1. Введение Эта статья изначально не планировалась как отдельная публикация. Она начиналась как введение к другой статье, над которой я сейчас работаю, но в итоге разрослась до такой степени, что я решил сделать её самостоятельным материалом.

В ней содержится обширный, актуальный практический материал обaccessed объектов устройств — наиболее распространённом способе взаимодействия с драйверами режима ядра Windows из пользовательского режима. В ней объясняются такие нюансы, как минимальные права доступа, необходимые для взаимодействия с драйвером, прямой доступ к объектам устройств, особенности доступа к устройствам в стеках устройств, а также дополнительные риски для драйверов с несколькими объектами устройств — и всё это проиллюстрировано практическими упражнениями.

В статье также оспариваются некоторые распространенные заблуждения и ложные предположения, которые иногда приводят к уязвимостям.

Основное внимание уделяется вектору взаимодействия как части поверхности атаки, а не какому-то конкретному классу уязвимостей. Хорошее понимание этих нюансов имеет решающее значение для точной оценки поверхности атаки и, как следствие, возможности эксплуатации конкретных случаев.

Этот материал предназначен для разработчиков драйверов режима ядра, исследователей безопасности, специалистов по обратной разработке и всех остальных, кто интересуется данной темой. Твердое понимание модели безопасности Windows и базовое знакомство с WinDbg будут большим плюсом.

2. Объекты устройств как наиболее жизнеспособный вектор атак на ядро

Большинство атак на драйверы режима ядра Windows связаны с использованием одного конкретного вектора взаимодействия из пользовательского режима в режим ядра — вызова DeviceIoControl() через дескрипторы устройств, полученные вызовом CreateFile(), точно так же, как в примере ниже:

DeviceIoControl() принимает два необязательных указателя на буфер — lpInBuffer и lpOutBuffer. Входные данные, контролируемые пользователем, поступают в драйвер через входной буфер, а драйвер заполняет выходной буфер в ответ. Системным компонентом, отвечающим за обработку этого взаимодействия, является диспетчер ввода-вывода (I/O manager), а структура данных, используемая в этом механизме, называется IRP.

Хотя атаковать драйверы Windows из пользовательского режима можно через целый ряд других векторов взаимодействия (таких как обратные вызовы на основе событий или порты связи фильтров, и это лишь некоторые из них), отправка IRP является наиболее распространенным вектором атак по двум причинам:

  • Это наиболее часто реализуемый вектор взаимодействия пользовательского режима с режимом ядра в драйверах Windows.
  • Он предлагает наиболее потенциально опасный ввод пользовательских данных (неструктурированный буфер данных переменного размера, передаваемый через IRP->SystemBuffer, который интерпретируется логикой драйвера индивидуально).

Очень важным свойством IRP является код MajorFunction. Когда дескриптор открывается, драйверу, стоящему за устройством, отправляется первый IRP с кодом MajorFunction = IRP_MJ_CREATE.

Если это завершается успешно, дескриптор становится действительным и может быть передан в качестве аргумента в DeviceIoControl(), вызов которого приводит к отправке еще одного IRP, на этот раз с MajorFunction = IRP_MJ_DEVICE_CONTROL.

Когда дескриптор закрывается, драйверу отправляется IRP с кодом MajorFunction = IRP_MJ_CLOSE. Если с дескриптором предпринималась попытка операции чтения или записи, драйвер получал бы IRP с IRP_MJ_READ или IRP_MJ_WRITE соответственно.

Если драйвер поддерживает определенную операцию, он реализует специальную функцию, называемую подпрограммой диспетчеризации IRP (IRP dispatch routine), и регистрирует ее для соответствующего кода MajorFunction.

Из пользовательского режима можно запрашивать лишь подмножество кодов MajorFunction. Далее в этой статье, говоря об IRP, мы будем ссылаться исключительно на IRP_MJ_DEVICE_CONTROL, если не указано иное.

В ядре соответствующий драйвер получает входной буфер как IRP->SystemBuffer для обработки в своей подпрограмме диспетчеризации IRP_MJ_DEVICE_CONTROL.

Хорошим, доступным примером реализации подпрограммы диспетчеризации IRP_MJ_DEVICE_CONTROL является HackSysExtremeVulnerableDriver.c, так как и DriverEntry, и функция, реализующая обработчик, находятся в одном файле.

Хотя целостное исследование всей поверхности атаки драйверов режима ядра Windows само по себе является увлекательным предметом, остальная часть этой статьи будет посвящена исключительно IRP (с IRP_MJ_DEVICE_CONTROL через DeviceIoControl() в качестве целевой подпрограммы диспетчеризации) как вектору взаимодействия/атаки из пользовательского режима в режим ядра.

3. Доступ к объектам устройств

Хотя способ доступа к объектам устройств до вызова DeviceIoControl() кажется очень простым, с ним связано несколько нюансов. Давайте их рассмотрим.

3.1 Стандартный шаблон

В первом примере кода, приведенном в этой статье, дескриптор объекта устройства получается в строке 18 вызовом CreateFile() следующим образом:

Давайте внимательнее рассмотрим первые два параметра (lpFileName и dwDesiredAccess) в контексте открытия дескрипторов объектов устройств.

3.2 Доступ к объектам устройств через символические ссылки и напрямую

В приведенном примере CreateFileW() получает строку с широкими символами \\.\SampleDrv в качестве параметра lpFileName. Параметр, который обычно принимает пути в стиле DOS, такие как «C:», в данном случае начинается с обратной косой черты, точно так же, как пути UNC.

Префикс \\.\

Префикс \\.\ — это специальный индикатор, который заставляет Windows обращаться к пространству имен устройств Win32 напрямую. Это перенаправляет запрос в пространство имен диспетчера объектов (OMNS) (пути в этом пространстве имен часто называют «путями NT»).

Как объясняет Microsoft:

Префикс "\\.\" обеспечивает доступ к пространству имен устройств Win32 вместо пространства имен файлов Win32. Именно так осуществляется прямой доступ к физическим дискам и томам без прохождения через файловую систему, если API поддерживает этот тип доступа. Таким образом можно получить доступ ко многим устройствам, помимо дисков (например, с помощью функций CreateFile и DefineDosDevice).

Префикс "\\.\" обеспечивает доступ к пространству имен устройств Win32 вместо пространства имен файлов Win32. Именно так осуществляется прямой доступ к физическим дискам и томам без прохождения через файловую систему, если API поддерживает этот тип доступа. Таким образом можно получить доступ ко многим устройствам, помимо дисков (например, с помощью функций CreateFile и DefineDosDevice).

Как мы можем далее прочитать в разделе о путях устройств DOS here, префикс \\.\ также называется спецификатором устройства DOS, наряду с \\?\. Оба пути \\.\ и \\?\ сопоставляются с \??\ в пространстве имен OMNS.

Преобразование из \\.\ в \??\ выполняется ntdll до того, как путь достигает диспетчера объектов.

Когда диспетчер объектов разрешает путь через \??, он выполняет двухэтапный поиск:

  • Сначала проверяется локальный каталог DosDevices для сеанса входа вызывающего абонента: \Sessions\0\DosDevices\<LogonSession-LUID>.
  • Если он там не найден, происходит переход к \GLOBAL?? (объект каталога, содержащий общесистемные символические ссылки на устройства DOS).

Каталог GLOBAL??

Лучший способ просмотреть все пространство имен диспетчера объектов Windows — использовать графический инструмент, такой как WinObj, WinObjEx64 или модуль PowerShell NtObjectManager.

На скриншоте ниже мы можем увидеть структуру OMNS и некоторое содержимое каталога \GLOBAL??\:

Итак, \GLOBAL?? — это место, куда направляются пути, начинающиеся с \??. \?? служит механизмом диспетчеризации, который находится перед ним, чтобы разрешить переопределение для каждого сеанса (например, сопоставления дисков subst локальны для сеанса входа и находятся в каталоге для конкретного сеанса, в то время как все остальное живет в \GLOBAL??).

Когда мы вызываем CreateFile(), разрешение пути происходит следующим образом:

\Device — это каталог OMNS, в котором находятся именованные объекты устройств.

Также стоит иметь в виду, что драйверы часто используют \DosDevices\ (глобальный вариант DosDevices) при создании символьных ссылок на свои объекты устройств.

Как мы видим на скриншоте ниже, \DosDevices — это просто символьная ссылка на \??, поэтому она также разрешается в \GLOBAL??:

Таким образом, всякий раз, когда мы передаем \\.\ в CreateFile(), мы используем символьную ссылку в \GLOBAL??.

Символьные ссылки в GLOBAL??

Создание символьных ссылок происходит во время инициализации драйвера (обычно где-то в дереве вызовов, начинающемся в DriverEntry).

Создав объект устройства в директории \Device, драйвер может связать его под произвольным именем, либо напрямую вызвав IoCreateSymbolicLink, либо через IoRegisterDeviceInterface.

Довольно часто объект устройства имеет то же имя, что и указывающая на него символьная ссылка в \GLOBAL??\, но оно также может быть совершенно другим. Драйвер также может создать свой объект устройства за пределами директории Device (например, я видел драйверы, создающие объект устройства прямо по пути \GLOBAL??\, полностью минуя символьную ссылку).

Создание символьной ссылки на устройство в \GLOBAL?? является необязательным, что означает, что не все созданные драйверами объекты устройств связаны там. Цель связывания объектов устройств в \GLOBAL?? заключается в том, чтобы сделать их доступными с префиксом пространства имен устройств \\.\, поэтому это происходит, если предполагается, что объект устройства должен быть доступен из пользовательского режима. Множество объектов устройств, находящихся в \Device, не связаны в \GLOBAL??.

Тем не менее, отсутствие символьной ссылки в \GLOBAL?? не является границей безопасности, хотя это и мешает обращению к объекту устройства с помощью CreateFile().

Существует возможность открывать дескрипторы объектов устройств напрямую из директории \Device, независимо от каких-либо символьных ссылок.

Согласно документации Microsoft: Тем не менее, дескриптор этого устройства может быть открыт с помощью любых API, поддерживающих абсолютные пути в пространстве имен NT формата “\Device\Xxx”.

Таким образом, мы можем полностью обойти CreateFile() и вызвать напрямую NtCreateFile() для открытия объекта устройства по его пути NT, получив дескриптор, который можно использовать с DeviceIoControl() точно так же, как и дескриптор на основе символьной ссылки — см. пример файла на C: dev_open_direct.c

Это следует иметь в виду при оценке поверхности атаки, особенно с точки зрения времени выполнения. Поступая таким образом, мы с большой долей вероятности обнаружим драйверы, которые — вопреки намерениям разработчика — позволяют коду пользовательского режима отправлять им IRP и доверяют им так, будто они пришли от другого драйвера (например, не проверяя RequestorMode IRP перед дальнейшей обработкой).

3.3 Нулевой требуемый доступ

В этом разделе объясняется, почему SYNCHRONIZE — это фактически минимальное право доступа, необходимое для наличия в дескрипторе безопасности объекта устройства, чтобы отправлять IOCTL через DeviceIoControl().

Коды IOCTL (открывается в новой вкладке) представляют собой 32-битные целые числа (DWORD), передаваемые в качестве второго аргумента в DeviceIoControl(). Проще говоря, их роль заключается в идентификации конкретной функциональности в подпрограмме диспетчеризации IRP. Поэтому обычно одна подпрограмма диспетчеризации поддерживает несколько IOCTL с соответствующими блоками кода за условиями, прямо как в HackSysExtremeVulnerableDriver.c:

В исходном коде HEVD коды IOCTL, используемые драйвером, определены в HackSysExtremeVulnerableDriver.h. HEVD генерирует их, вызывая собственный макрос с именем IOCTL, который определен в том же заголовочном файле и представляет собой просто обертку над макросом CTL_CODE, определенным в devioctl.h, входящем в состав WDK.

Подробную структуру IOCTL и описание макроса CTL_CODE можно найти на этой странице.

Хотя значения IOCTL, используемые в драйвере, выбираются разработчиком, они не являются полностью случайными или произвольными.

Каждый IOCTL несет четыре различных фрагмента информации: тип устройства (Device Type), метод (Method), доступ (Access) и функцию (Function).

Для проверки этих значений для IOCTL мы можем использовать команду !ioctldecode в WinDBG (также доступную в сеансе отладки пользовательского режима):

Я хочу сосредоточиться только на одном из этих свойств — доступе (Access). Он может принимать одно из следующих четырех значений: FILE_ANY_ACCESS, FILE_READ_ACCESS, FILE_WRITE_ACCESS, FILE_READ_WRITE_ACCESS.

Важно понимать, что свойство Access не влияет на то, может ли пользователь открыть дескриптор устройства, но влияет на то, будет ли разрешена или запрещена отправка конкретного IOCTL через предварительно открытый дескриптор. Что логично, так как IOCTL передается только в DeviceIoControl() (которая вызывается, когда дескриптор уже открыт и действителен), а не в CreateFile().

Может ли пользователь открыть дескриптор объекта устройства, определяется дескриптором безопасности целевого объекта устройства.

После получения дескриптора можно вызвать DeviceIoControl(), передав параметры, включая IOCTL, в диспетчер ввода-вывода (I/O Manager). Диспетчер ввода-вывода декодирует IOCTL, извлекает внедренное свойство Access и сверяет его с разрешениями, предоставленными для дескриптора. На практике это означает, например, что если мы пытаемся отправить IOCTL с Access = FILE_READ_WRITE_ACCESS, используя дескриптор, полученный с помощью CreateFile(path,GENERIC_READ,…), DeviceIoControl() завершится с ошибкой 5 (ACCESS_DENIED), и наш IRP никогда не попадет в подпрограмму диспетчеризации DeviceIoControl().

Существует множество способов просмотра дескриптора безопасности объекта устройства. Мы можем использовать графический инструмент, такой как WinObj или WinObjEx64, мы можем использовать WinDBG или — что является моим личным фаворитом — NtObjectManager. NtObjectManager чрезвычайно полезен для экспериментов и всего, что требует программного доступа:

Получить дескриптор безопасности так же просто, как вызвать Get-NtSecurityDescriptor. Мы также можем легко вывести его в формате SDDL:

Устройство, которое мы используем в качестве примера, связано в \GLOBAL?? как PhysicalDrive0:

При изучении SDDL стоит иметь в виду, что подмножество применимых строк прав доступа (таких как GA для GENERIC_ALL) зависит от типа целевого объекта. Объекты устройств используют то же подмножество прав доступа SDDL, что и объекты файлов (SDDL для объектов устройств).

Дескриптор безопасности для \Device\Harddisk0\DR0 имеет пять элементов управления доступом (ACE), все типа Allow: (A;;FA;;;BA)(A;;FA;;;SY)(A;;FX;;;WD)(A;;FX;;;RC)(A;;0x12019f;;;UD).

(A;;FA;;;SY) и (A;;FA;;;BA) предоставляют полный доступ (FA) для NT AUTHORITY/SYSTEM (SY) и Builtin Administrators (BA) соответственно (что понятно само по себе и ожидаемо). Также имеется ACE для UD (драйверы пользовательского режима) и пара дополнительных ACE, предоставляющих доступ, указанный как FX, группам с псевдонимами WD (World) и RC (Restricted Code). Как поясняется в документации Microsoft: любой ACL, определяющий RC, также должен определять WD. Когда RC сопряжен с WD в ACL, описывается надмножество Everyone, включающее ненадежный код.

FX — это псевдоним для FILE_GENERIC_EXECUTE, как мы можем узнать здесь, значение которого очевидно в контексте исполняемых файлов пользовательского режима, но напрямую не соотносится с объектом устройства (так как объект устройства нельзя «выполнить»).

Изучая winnt.h, мы можем видеть, что FILE_GENERIC_EXECUTE — это один коллективный псевдоним прав доступа, объединяющий STANDARD_RIGHTS_EXECUTE, FILE_READ_ATTRIBUTES, FILE_EXECUTE и SYNCHRONIZE.

Обратите внимание, что в этом наборе разрешений отсутствует GENERIC_READ.

Поэтому неудивительно, что следующий вызов CreateFile, запрашивающий GENERIC_READ, завершается с кодом ошибки 5 (доступ запрещен) при вызове из токена обычного пользователя:

Пример программы, запрашивающей дескриптор GENERIC_READ для PhysicalDrive0

Теперь давайте изменим dwDesiredAccess на что-то другое, например на 0:

Пример также был дополнен фактическим использованием DeviceIoControl() для отправки реального IOCTL, поддерживаемого disk.sys (это драйвер, объект устройства которого мы открываем):

IOCTL_DISK_GET_DRIVE_GEOMETRY определен в winioctl.h, и даже без проверки его фактического значения DWORD и его последующей декодировки мы можем видеть, что он имеет FILE_ANY_ACCESS, заглянув в его определение в заголовочном файле:

Этот пример наглядно демонстрирует, что FILE_ANY_ACCESS означает, что нам вообще не нужны разрешения GENERIC_READ или GENERIC_WRITE.

Что в данном случае кажется нелогичным, так это то, что мы ожидали бы, что dwDesiredAccess = 0 будет буквально означать нулевой доступ, в то время как мы должны иметь некоторый доступ, чтобы успешно открыть дескриптор.

Что на самом деле происходит «под капотом»:

  • В kernelbase.dll функция CreateFileW() вызывает CreateFileInternal(), передавая запрошенный dwDesiredAccess в качестве второго аргумента.
  • Затем CreateFileInternal() выполняет над ним логическую операцию ИЛИ с маской 0x100080 (DesiredAccess |= SYNCHRONIZE | FILE_READ_ATTRIBUTES).
  • Затем это значение передается (также в качестве второго аргумента) в NtCreateFile().

Мы можем подтвердить, что 0x100080 на самом деле представляет собой SYNCHRONIZE | FILE_READ_ATTRIBUTES, еще раз изучив winnt.h:

Это также можно подтвердить эмпирически путем изменения примера dev_open_direct.c (который использует нативную функцию NtCreateFile() для прямого открытия объектов устройств, обходя символические ссылки, если они существуют). В этом примере право SYNCHRONIZE явно передается в NtCreateFile() в качестве второго аргумента:

3.4 Именованные и безымянные устройства

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

Существует также ряд нюансов, касающихся безымянных объектов устройств в контексте их дескрипторов безопасности. Если мы прочитаем статью MSDN для IoCreateDevice, мы встретим следующее предложение:

Если имя устройства не указано (то есть DeviceName равно NULL), создаваемый функцией IoCreateDevice объект устройства не будет (и не может) иметь связанного с ним списком контроля доступа (DACL).

Если имя устройства не указано (то есть DeviceName равно NULL), создаваемый функцией IoCreateDevice объект устройства не будет (и не может) иметь связанного с ним списком контроля доступа (DACL).

Это утверждение наводит на мысль, что безымянные объекты устройств не могут иметь дескрипторов безопасности и фактически не являются объектами, для которых можно настроить безопасность, но на практике это не так. Мы рассмотрим дескрипторы безопасности безымянных объектов устройств подробнее чуть позже.

3.5 Управляющие устройства против PnP-устройств

Если мы хотим лучше понять поверхность атаки, создаваемую объектами устройств, нам нужно понять разницу между:

  • Объектами устройств без поддержки PnP — объектами управляющих устройств (CDO).
  • Объектами устройств, поддерживающими технологии Plug and Play (PnP) или управление питанием — функциональными объектами устройств (FDO), физическими объектами устройств (PDO) и объектами фильтров устройств (FiDO).

С точки зрения безопасности наиболее важное различие между этими двумя группами заключается в следующем:

  • CDO — это всегда именованные автономные объекты устройств, предназначенные для непосредственного взаимодействия с пользовательским режимом. IRP отправляется одному драйверу.
  • PnP-объекты устройств организованы в стеки PnP-устройств, в которых (обычно) только одно устройство — PDO — является именованным устройством. IRP проходит через весь стек устройств.

3.6 Стеки устройств

Как написано на странице узлов и стеков устройств MSDN: последовательность объектов устройств вместе с сопутствующими драйверами называется стеком устройств. Каждый узел устройства имеет собственный стек устройств.

Понимание стеков устройств имеет решающее значение при оценке поверхности атаки драйверов режима ядра.

3.6.1 Прохождение IRP

Предположим, у нас есть следующий стек устройств сверху вниз:

  • UPFLT.sys (объект верхнего фильтра DO, безымянный)
  • A.sys (FDO, безымянный)
  • B.sys (FiDO, безымянный)
  • C.sys (PDO, именованный, \Device\Sample_C)

В стеке устройств IRP, отправленный через дескриптор объекта устройства, проходит через весь стек устройств сверху вниз.

Если пользователь успешно открывает дескриптор \Device\Sample_C, сначала IRP с кодом MajorFunction = IRP_MJ_CREATE обходит стек сверху вниз, вызывая индивидуальные подпрограммы диспетчеризации IRP_MJ_CREATE в каждом драйвере:

IRP не распространяется каскадом автоматически. Каждый драйвер должен явно вызвать IoCallDriver(lowerDeviceObject, Irp), чтобы передать его дальше. Если какой-либо драйвер в стеке вызывает ошибку или блокирует IRP_MJ_CREATE IRP, мы вообще не получаем дескриптор.

Если дескриптор открывается успешно и мы вызываем для него DeviceIoControl(), снова будет вызвана цепочка подпрограмм диспетчеризации IRP_MJ_DEVICE_CONTROL:

Это фактически означает, что объекты устройств становятся частью поверхности атаки IRP, как только они подключаются к стеку PnP-устройств. Именно так работают драйверы фильтров, располагаясь между другими объектами устройств и обрабатывая IRP по ходу дела.

3.6.2 Исследование стеков устройств

Лучший способ изучить стеки устройств — использовать WinDBG (требуется сеанс живого отладчика ядра) с помощью команд !drvobj и !devnode.

Давайте изучим disk.sys, в который мы уже отправили IOCTL в disk_geometry.c в качестве примера запроса dwDesiredAccess = 0.

Мы начинаем с этой команды: !drvobj \Driver\disk 7.

Число во втором аргументе (7) указывает, насколько подробно мы хотим, чтобы !drvobj выполнял вывод. Имейте в виду, что эта команда требует больших ресурсов, поэтому абсолютно нормально ждать вывода в течение 30 секунд или более. Для краткости я покажу и прокомментирую только те фрагменты ее вывода, которые имеют отношение к делу. Это первая важная часть:

В данном конкретном случае у драйвера есть только один объект устройства, адрес которого мы видим после Device Object list:. Если за этим выводом следует пустая строка вместо хотя бы одного адреса, это означает, что у драйвера нет объектов устройств. Это чрезвычайно распространенное явление, особенно когда мы развертываем драйвер в системе без соответствующего физического оборудования.

После списка объектов устройств идут адреса ключевых функций драйвера, таких как DriverEntry, DriverStartIo, DriverUnload, AddDevice и все подпрограммы диспетчеризации.

Затем мы получаем примерно следующее:

Обратите внимание, что весь этот блок текста по-прежнему является цитатой из вывода той же самой отдельной команды. Я подчеркиваю это, потому что наличие таких меток, как !devstack, !DevObj, !DrvObj и !DevExt, может сбить с толку некоторых читателей (меня это сбивает с толку каждый раз, когда я это вижу). Уровень детализации 7 заставил !drvobj автоматически запустить !devstack для каждого объекта устройства (в данном случае только одного, ffffc508e354c060).

Его вывод показывает нам, что единственный объект устройства, принадлежащий disk.sys, называется DR0, и что он находится между безымянным объектом, принадлежащим драйверу partmgr (выше), и другим именованным объектом устройства 0000002b (автоматически сгенерированное имя, типичное для PDO), принадлежащим драйверу storvsc.

Также обратите внимание, что в этом стеке устройств у нас есть два именованных объекта устройств. Мы вернемся к этому чуть позже.

3.6.3 Атака на драйверы через стеки устройств

Внимательный читатель заметит, что в более раннем примере disk_geometry.c мы уже взаимодействовали с объектом PnP-устройства, существующим в стеке устройств. Мы отправили IRP с пользовательским IOCTL (определенным как IOCTL_DISK_GET_DRIVE_GEOMETRY), поддерживаемым драйвером disk.sys, через дескриптор, открытый вызовом CreateFile() для \\.\PhysicalDrive0, символьной ссылки на \Device\Harddisk0\DR0. И мы получили доступ к функционалу геометрии диска.

Теперь предположим, что у нас есть драйвер фильтра дисков с именем VulnDisk.sys, привязанный к тому же стеку устройств в качестве верхнего фильтра (UpperFilter) для класса устройств Disk Drives, добавленный в HKLM:{4d36e967-e325-11ce-bfc1-08002be10318}.

Теперь стек устройств выглядит следующим образом:

Итак, у нас есть новый (безымянный) объект устройства поверх стека устройств диска.

Теперь где-то в подпрограмме диспетчеризации IRP_MJ_DEVICE_CONTROL драйвер VulnDisk.sys содержит уязвимость в блоке кода, который активируется только при отправке ему определенного IOCTL:

Концепт-доказательство (PoC), эксплуатирующий эту уязвимость и заставляющий драйвер получить наш IRP и передать наш входной буфер в функцию CriticallyVulnerableFunction(), начинается точно так же, как и наш предыдущий пример disk_geometry.c:

Верно, нет никакой разницы в том, как получается дескриптор, поскольку дескриптор, который мы открываем для именованного объекта устройства, дает нам доступ ко всему стеку устройств.

Разница начинается со значения параметра IOCTL, передаваемого в DeviceIoControl(), и, конечно же, размер и содержимое входного буфера совершенно другие.

Этот паттерн применим к ряду критических уязвимостей, которые я обнаружил и о которых ответственно сообщил (проблема с драйвером фильтра дисков на момент написания этой статьи все еще находится в стадии решения).

Вторая уязвимость — CVE-2025-13348. Поскольку этот драйвер является фильтром томов хранения, единственное различие в способе вызова CreateFile() заключалось в замене \\.\PhysicalDrive0 на \\.\C:.

Если кто-то заинтересуется более глубоким погружением в тему атак на драйверы-фильтры, я рекомендую эту статью.

3.6.4 Дескрипторы безопасности безымянных объектов устройств

Вопреки тому, на что намекает документация IoCreateDevice, безымянные устройства могут иметь и имеют дескрипторы безопасности. Функция просто не позволяет указывать их явно, если мы передаем пустое значение DeviceName.

Давайте проведем простое упражнение для проверки этого, создав чрезвычайно простой драйвер (без обработки IRP, без подпрограмм диспетчеризации). Исходный код можно найти здесь.

Его единственная роль — создать новый безымянный объект устройства непосредственно из функции DriverEntry. После возврата из DriverEntry драйвер остается загруженным, а созданный им объект устройства можно вручную изучить из отладчика, чтобы мы могли самостоятельно проверить, есть ли у него дескриптор безопасности:

При успешном создании драйвер также вызывает DbgPrint(), поэтому адрес вновь созданного объекта устройства появляется в выводе отладчика.

Создание:

Запуск:

Затем мы заглядываем в WinDBG:

DbgPrint() вывела адрес вновь созданного безымянного объекта устройства (FFFF830CE5AE2E10).

Давайте изучим его с помощью команды !devobj:

[IMAGE HERE: !devobj FFFF830CE5AE2E10]

  • SDDL

О чём эта статья