В этой статье приводятся подробности CVE-2023-21822 — уязвимости Use-After-Free (UAF) в win32kfull, которая может привести к повышению привилегий. Отчёт о баге отправлен в рамках программы ZDI, а позже она была пропатчена компанией Microsoft.
В ядре Windows есть три API, предназначенные для общего использования драйверами устройств с целью создания растровых изображений (bitmap): EngCreateBitmap
, EngCreateDeviceBitmap
и EngCreateDeviceSurface
. Каждый из этих API возвращает дескриптор растрового изображения. Если вызывающая сторона хочет выполнить какие-то операции рисования на растровом изображении, то она должна сначала заблокировать это изображение, передав его дескриптор функции EngLockSurface
. EngLockSurface
увеличивает значение эталонного счётчика растрового изображения и возвращает указатель на соответствующую запись SURFOBJ
. SURFOBJ
— это расположенная в памяти ядра структура, содержащая всю информацию, связанную с растровым изображением, например, его размер, формат пикселей, указатель на пиксельный буфер и так далее. Подробнее структуру SURFOBJ
мы рассмотрим позже.
После вызова EngLockSurface
полученный указатель на SURFOBJ
может передаваться различным API рисования, например, EngLineTo
и EngBitBlt
. Полный список этих API рисования можно найти в winddi.h. После того, как вызывающая сторона завершит операции рисования, она должна вызывать EngUnlockSurface
. На этом этапе эталонный счётчик растрового изображения снова сбрасывается до нуля, и вызывающей стороне больше не разрешается использовать указатель на SURFOBJ
. В конце вызывающая сторона может удалить растровое изображение, вызвав для его дескриптора EngDeleteSurface
. Типичное использование этих API показано ниже:
// ПРИМЕЧАНИЕ: этот пример кода должен выполняться драйвером режима ядра.
HBITMAP TestBitmap = EngCreateBitmap(...);
// эталонный счётчик растрового изображения равен 0
SURFOBJ* pso = EngLockSurface(TestBitmap);
// эталонный счётчик растрового изображения равен 1
// выполняем операции рисования на растровом изображении…
EngLineTo(pso, ...);
...
// эталонный счётчик растрового изображения равен 1
EngUnlockSurface(pso);
// эталонный счётчик растрового изображения равен 0
EngDeleteSurface(TestBitmap);
Все упомянутые выше API экспортируются из модуля режима ядра win32k.sys
. Однако стоит отметить, что функции в win32k.sys
— это лишь обёртки, а реализации находятся в win32kbase.sys
и win32kfull.sys
.
Много лет назад драйверы и дисплеев, и принтеров работали в режиме ядра, но начиная с Windows Vista драйверы принтеров работают только в пользовательском режиме (отсюда и название User-Mode Printer Driver, или UMPD). Из этого изменения следуют два важных факта:
- Во время операций печати ядро теперь должно выполнять обратные вызовы в пользовательский режим, чтобы вызвать соответствующий драйвер принтера пользовательского режима.
- Чтобы код драйвера принтера мог выполняться в пользовательском режиме, какие-нибудь API ядра должны быть доступны из этого режима.
В результате этого все описанные выше API ядра имеют аналоги для пользовательского режима, экспортированные из модуля пользовательского режима gdi32.dll
. Давайте попробуем исполнить тот же код, показанный выше, но на этот раз из пользовательского режима:
// ПРИМЕЧАНИЕ: этот пример кода должен выполняться драйвером принтера в пользовательском режиме.
HBITMAP TestBitmap = EngCreateBitmap(...);
// эталонный счётчик растрового изображения равен 0
SURFOBJ* pso = EngLockSurface(TestBitmap);
// эталонный счётчик растрового изображения равен 0 !!!
// выполняем операции рисования с растровым изображением...
EngLineTo(pso, ...);
...
// эталонный счётчик растрового изображения равен 0 !!!
EngUnlockSurface(pso);
// эталонный счётчик растрового изображения равен 0
EngDeleteSurface(TestBitmap);
Обратите внимание на значения эталонного счётчика, показанные в комментариях. После блокировки растрового изображения значение остаётся равным нулю. Почему?
Код режима ядра всегда считается надёжным, а коду пользовательского режима система всегда не доверяет. Поэтому теперь, когда драйверы принтера исполняются в пользовательском режиме, они считаются ненадёжными и потенциально зловредными.
Предположим, что вызов EngLockSurface
пользовательского режима увеличивал бы эталонный счётчик растрового изображения так же, как версия режима ядра. Нападающий, действующий как драйвер принтера пользовательского режима, много раз вызывал бы в цикле EngLockSurface
для растрового изображения, чтобы переполнить эталонный счётчик растрового изображения, и это привело бы к его сбросу в ноль. Тогда растровое изображение можно было бы удалить, что позволило бы использовать уязвимость use-after-free для растрового изображения.
Поэтому в ядре Windows реализован другой подход. От API EngLockSurface
ожидается, что он вернёт указатель на запись SURFOBJ
растрового изображения, и он это делает. Но в пользовательском режиме это копия «истинной» записи SURFOBJ
режима ядра. Мы можем воссоздать эту структуру данных пользовательского режима следующим образом:
typedef struct _UMSO {
ULONG magic; // 0x554D534F = “UMSO” (User-Mode Surface Object?)
HBITMAP hsurf; // дескриптор растрового изображения
SURFOBJ so; // копия записи SURFOBJ режима ядра
} UMSO;
Реализация EngLockSurface
пользовательского режима возвращает указатель на поле UMSO.so
, которое является копией истинной записи SURFOBJ
режима ядра, поэтому всё работает, как и задумано. Внутри вызов EngLockSurface
пользовательского режима переходит к своей реализации win32kfull.sys!NtGdiEngLockSurface
режима ядра, где запись UMSO
пользовательского режима распределяется и заполняется. В режиме ядра выполняется «истинный» вызов EngLockSurface
режима ядра к растровому изображению, которому необходим доступ к записи SURFOBJ
растрового изображения, чтобы её данные можно было скопировать в поле UMSO.so
. Однако затем NtGdiEngLockSurface
вызывает EngUnlockSurface
режима ядра, который снова сбрасывает эталонный счётчик растрового изображения до нуля. Это объясняет наблюдаемые значения эталонного счётчика.
Вызвав EngLockSurface
пользовательского режима, мы можем передать его результат (то есть указатель на скопированные данные SURFOBJ
) различным функциям рисования, например, EngLineTo
или EngBitBlt
. Когда соответствующие вызовы выполняются из режима ядра, это работает очень просто, однако при вызове из пользовательского режима необходим дополнительный слой для преобразования указателей SURFOBJ
пользовательского режима в истинные указатели режима ядра. То есть, например, если код пользовательского режима вызывает gdi32.dll!EngLineTo
, то будет выполнен переход к обёртке win32kfull.sys!NtGdiEngLineTo
режима ядра. Обёртка получит истинную запись растрового изображения SURFOBJ
режима ядра, поэтому в конечном итоге можно будет выполнить обработчик рисования win32kfull.sys!EngLineTo
режима ядра.
Как ядро получает необходимую запись SURFOBJ
режима ядра? Запись SURFOBJ
содержит уязвимые данные, например, указатель на пиксельный буфер растрового изображения, поэтому ядро никогда не полагается на содержимое записей SURFOBJ
, передаваемых из пользовательского режима. В противном случае возникала бы угроза безопасности со стороны зловредного кода пользовательского режима, который мог бы вмешиваться в содержимое структур UMSO.so
. Поэтому вместо этого в функции обёртки (допустим, win32kfull.sys!NtGdiEngLineTo
из примера выше) ядро верифицирует значение UMSO.magic
, а затем использует значение дескриптора растрового изображения UMSO.hsurf
для блокировки растрового изображения вызовом EngLockSurface
. Благодаря этому ядро безопасно получает запрашиваемую запись растрового изображения SURFOBJ
режима ядра, которую затем может передать соответствующей функции рисования win32kfull.sys!EngXXX
режима ядра.
▍ Уязвимость
Функция EngLockSurface
пользовательского режима выполняет валидацию переданного дескриптора растрового изображения, то есть этому вызову может быть успешно передан не любой тип растрового изображения (подробнее мы поговорим об этом ниже). Однако зловредный код пользовательского режима может обойти эту проверку одним из следующих способов:
- После выполнения вызова
EngLockSurface
мы можем удалить уже валидированное растровое изображение и создать какое-то другое с тем же значением дескриптора. При этом можно создать растровое изображение, которое нельзя успешно передать функцииEngLockSurface
. - Выполнив вызов
EngLockSurface
, мы получаем указатель на записьSURFOBJ
пользовательского режима, которая, как мы уже знаем, является частью записиUMSO
. То есть мы можем переписать полеUMSO.hsurf
, присвоив ему значение дескриптора любого нужного нам растрового изображения. Можно присвоить значение дескриптора растрового изображения, которое не может быть успешно передано функцииEngLockSurface
. - И самое простое: мы можем подготовить запись
UMSO
с нуля, не выполняя предварительно никаких вызововEngLockSurface
. Нам достаточно распределить память пользовательского режима, присвоитьUMSO.magic
значение0x554D534F
, аUMSO.hsurf
присвоить дескриптор нужной нам растрового изображения. Оставшуюся часть этой записи (полеUMSO.so
, в обычных обстоятельствах содержащее записьSURFOBJ
) можно обнулить, потому что её в любом случае ядро проигнорирует.
Каждый из трёх вариантов позволит нам обойти валидацию растровых изображений, выполняемую версией API EngLockSurface
пользовательского режима.
Разобравшись, как можно обойти валидацию, нужно задаться вопросом: в чём же цель этой валидации и какие последствия это имеет для безопасности? Чтобы ответить на этот вопрос, мы должны взглянуть на определение записи SURFOBJ
. Некоторые поля публично документированы, а другие можно воссоздать, как показано ниже:
typedef struct _SURFOBJ {
DHSURF dhsurf; // важно для нас
HSURF hsurf;
DHPDEV dhpdev;
HDEV hdev; // важно для нас
SIZEL sizlBitmap;
ULONG cjBits;
PVOID pvBits;
PVOID pvScan0;
LONG lDelta;
ULONG iUniq;
ULONG iBitmapFormat;
USHORT iType;
USHORT fjBitmap;
...
// здесь начинается незадокументированная часть
ULONG flags; // важно для нас
ULONG flags2;
...
} SURFOBJ;
Поле flags
растрового изображения не задокументировано, но известно, что оно содержит несколько задокументированных флагов HOOK_XXX
, находящихся в файле заголовка winddi.h. Эти флаги сообщают подсистеме win32k, какие операции рисования должны обрабатываться самой win32k, а какие должны перенаправляться специализированному драйверу устройства. Драйвер устройства указан в поле hdev
растрового изображения.
Например, предположим, что мы хотим нарисовать на каком-то растровом изображении линию. Мы вызовем EngLineTo
, передав указатель на запись SURFOBJ
растрового изображения. Внутри ядро преобразует запрошенную линию в более общую конструкцию рисования под названием «path» (контур) (который может быть последовательностью отрезков и кривых). Затем оно проверит, установлен ли в поле SURFOBJ.flags
растрового изображения флаг HOOK_STROKEPATH
. Если этого флага нет, то ядро будет использовать обобщённый код рисования («штрихования») контуров, переданных win32kfull
. Однако если HOOK_STROKEPATH
есть, то ядро направит запрос рисования драйверу устройства, указанному в поле SURFOBJ.hdev
. Второй вариант, если это возможно, обеспечивает повышенную производительность, поскольку позволяет отдельным драйверам устройств пользоваться ускорением, предоставляемым оборудованием. Например, графический адаптер может иметь функцию аппаратно ускоренного рисования контуров. Аналогично, устройства принтеров имеют специализированное ускорение для вывода текста.
То есть если мы подготовим растровое изображение, имеющее связанное с экраном значение SURFOBJ.hdev
, у которого также задан флаг HOOK_XXX
, и передадим его одному из API рисования EngXXX
, то есть возможность достичь входной точки специализированного драйвера дисплея, работающего в режиме ядра. В случае использования одного монитора это может быть cdd.dll!DrvXXX
, а в случае использования нескольких — win32kfull.sys!MulXXX
(однако, как показано в примере выше, не всегда есть простая связь между запрошенной функциональностью и вызываемой входной точкой драйвера). Указатель на запись SURFOBJ
растрового изображения будет передан входной точке драйвера как параметр.
Стоит также отметить, что некоторые API EngXXX
получают в качестве параметра не одно растровое изображение, а два: исходное и конечное растровые изображения (некоторые опционально получают растровое изображение маски, но нас это не интересует). Примером такого API является EngBitBlt
, который копирует прямоугольник пикселей из исходного растрового изображения в конечное. Работающие с двумя растровыми изображениями API для выбора драйвера устройства, который получит вызов, используют значения SURFOBJ.flags
и SURFOBJ.hdev
конечного растрового изображения. Тем не менее, при вызове входной точки окончательно выбранного драйвера ей передаются и исходное, и конечное растровые изображения.
То есть правильно подготовленное связанное с экраном растровое изображение при передаче какому-нибудь API EngXXX
в качестве конечного растрового изображения позволяет нам добраться до драйвера дисплея режима ядра, а также передать в качестве исходного растрового изображения произвольное.
Пока проблема безопасности неочевидна, но давайте ещё раз взглянем на определение записи SURFOBJ
. Она содержит поле dhsurf
(не путать с рассмотренным выше полем hsurf
). Подсистема win32k работает с SURFOBJ.dhsurf
как с непрозрачным значением. Оно зарезервировано для отдельных драйверов устройств с целью применения для их внутренних задач. Этому полю можно легко присвоить новое растровое изображение: API создания растровых изображений EngCreateDeviceBitmap
и EngCreateDeviceSurface
получают в качестве параметра только dhsurf
. И Canonical Display Driver (cdd.dll
, применяемый для графического вывода на один монитор), и многодисплейный драйвер (win32kfull.sys!MulXXX
) ожидают, что будут работать только с собственными растровыми изображениями, значения SURFOBJ.dhsurf
которых задаются этим конкретным драйвером, а не с произвольными растровыми изображениями, созданными из пользовательского режима (или другими драйверами). Внутри каждый из этих драйверов использует значение SURFOBJ.dhsurf
в качестве указателя на блок в памяти режима ядра, который содержит приватные данные, принадлежащие этому драйверу.
Но мы можем добраться до драйвера дисплея режима ядра, передав правильно подготовленную конечную растровое изображение вызову EngXXX
, а также передав какое-нибудь произвольное растровое изображение на свой выбор в качестве исходного растрового изображения тому же вызову EngXXX
. Это исходное растровое изображение может быть созданным нами произвольным растровым изображением, а его значение SURFOBJ.dhsurf
может указывать на произвольную контролируемую память. Драйвер дисплея режима ядра, например, Canonical Display Driver, будет работать с этим блоком памяти, как если бы это был его собственный блок памяти режима ядра. А это означает «геймовер» для безопасности.
Поэтому реализация EngLockSurface
пользовательского режима имеет валидацию, чтобы отклонять связанные с экраном растровые изображения, которые можно использовать для того, чтобы добраться до драйвера дисплея режима ядра. Но благодаря описанной выше уязвимости мы можем легко обойти эту валидацию EngLockSurface
. На самом деле, мы можем вообще обойтись без вызова EngLockSurface
, и просто подготовить с нуля необходимую запись UMSO
, как и объяснялось выше.
▍ Эксплойт
Первым делом нужно отметить, что вызовы EngXXX
пользовательского режима предназначены только для применения драйверами принтеров пользовательского режима, поэтому большинство этих API не смогут выполнить задачу, если только их не вызвали во время обратного вызова из режима ядра в пользовательский режим для операции печати. Но это не особо усложняет задачу: часть обратного вызова пользовательского режима реализована как функция gdi32.dll!GdiPrinterThunk
, которая является публичным экспортом из gdi32.dll
. Этого достаточно, чтобы перехватить или пропатчить эту функцию и выполнить в ней наш основной эксплойт. Эта функция получает четыре параметра (входной буфер, размер входного буфера, выходной буфер и размер выходного буфера), но во время эксплойта нам не нужны эти параметры. (Однако если вам любопытны подробности, то см. Selecting Bitmaps into Mismatched Device Contexts. В частности, изучите разделы «User-Mode Printer Drivers (UMPD)» и «Hooking the UMPD implementation».)
Первым делом нам нужно получить обратный вызов от ядра к перехваченной функции gdi32.dll!GdiPrinterThunk
. Чтобы добиться этого, нам нужно инициировать операцию печати. Сначала мы должны найти установленный принтер. На каждой машине с Windows есть как минимум один виртуальный принтер, установленный по умолчанию. Обнаружить установленные принтеры можно при помощи вызова API winspool.drv!EnumPrintersA/W
пользовательского режима. Затем мы должны создать связанный с принтером контекст устройства:
CreateDC(“Microsoft XPS Document Writer”, “Microsoft XPS Document Writer”, NULL, NULL);
Этот вызов будет передан режиму ядра, который затем снова выполнит множество обратных вызовов к пользовательскому режиму, то есть можно будет вызвать нашу перехваченную функцию gdi32.dll!GdiPrinterThunk
, как мы того и хотели. Здесь начинается наша основная фаза эксплойта.
Сначала нам нужно получить растровое изображение со связанным с экраном значением SURFOBJ.hdev
и полезный флаг HOOK_XXX
, заданный в её поле SURFOBJ.flags
. Для получения такой растрового изображения мы можем создать окно с нужными параметрами, получить контекст устройства окна, а затем взять внутреннее растровое изображение. Полученное растровое изображение будет использоваться в качестве конечного растрового изображения:
HelperWindow = CreateWindowEx(WS_EX_TOOLWINDOW, "BUTTON", NULL,
WS_VISIBLE | WS_POPUP | WS_BORDER | WS_DISABLED,
0, 0, 50, 50, NULL, NULL, hInstance, NULL);
HelperWindowDCScr = GetWindowDC(HelperWindow);
HelperWindowBitmap = GetCurrentObject(HelperWindowDCScr, OBJ_BITMAP);
Также нам нужно исходное растровое изображение, поле SURFOBJ.dhsurf
которого указывает на контролируемую нами память пользовательского режима (наш FakeDhsurfBlock
):
sizl.cx = 100;
sizl.cy = 100;
MaliciousBitmap = EngCreateDeviceBitmap(&FakeDhsurfBlock, sizl, BMF_1BPP);
Теперь мы можем подготовить две записи UMSO
, одну для конечного, другую для исходного растрового изображения:
FillMemory(&UmsoDest, sizeof(UmsoDest), 0);
UmsoDest.magic = 0x554D534F;
UmsoDest.hsurf = HelperWindowBitmap;
FillMemory(&UmsoSrc, sizeof(UmsoSrc), 0);
UmsoSrc.magic = 0x554D534F;
UmsoSrc.hsurf = MaliciousBitmap;
Теперь у нас есть всё необходимое, чтобы выполнить зловредный вызов EngXXX
с нашими растровыми изображениями. У нашего связанного с экраном конечного растрового изображения будут заданы все флаги HOOK_XXX
, и мы сможем выбрать любой из API EngXXX
, получающих два растровые изображения:
rclDest.left = 0;
rclDest.top = 0;
rclDest.right = 10;
rclDest.bottom = 10;
rclSrc.left = 0;
rclSrc.top = 0;
rclSrc.right = 20;
rclSrc.bottom = 20;
EngStretchBltROP(&UmsoDest.so, &UmsoSrc.so, NULL, NULL, NULL, NULL, NULL,
&rclDest, &rclSrc, NULL, BLACKONWHITE, NULL, 0xCCCC);
Благодаря реверс-инжинирингу внутренностей Canonical Display Driver или многодисплейного драйвера, мы можем узнать, как подготовить FakeDhsurfBlock
пользовательского режима так, чтобы драйвер дисплея обеспечил примитивы памяти с возможностью эксплойта.
▍ Патч
Как говорилось выше, каждый из API рисования EngXXX
пользовательского режима (например, EngLineTo
и EngBitBlt
) вызывает соответствующую обёртку win32kfull.sys!NtGdiEngXXX
режима ядра, в которой, среди прочего, указатели SURFOBJ
пользовательского режима преобразуются в указатели SURFOBJ
режима ядра. Затем вызывается конечная точка драйвера win32kfull.sys!EngXXX
режима ядра для выполнения запрошенной операции рисования.
Хотя это не связано с нашей уязвимостью, стоит отметить, что во время обратного вызова gdi32.dll!GdiPrinterThunk
пользовательского режима ядро хранит отображение известных записей SURFOBJ
пользовательского режима на записи SURFOBJ
режима ядра. Когда драйвер принтера пользовательского режима передаёт указатель SURFOBJ
пользовательского режима какому-то вызову EngXXX
пользовательского режима, то ядро пытается использовать это отображение для нахождения соответствующего указателя SURFOBJ
режима ядра, чтобы его можно было передать соответствующему вызову EngXXX
режима ядра.
Отображение подготавливается до начала обратного вызова GdiPrinterThunk
пользовательского режима. Это вызвано тем, что некоторые растровые изображения могут передаваться обратному вызову в качестве параметров (хотя во время нашего эксплойта мы не пользовались входными данными GdiPrinterThunk
). Однако это означает, что растровые изображения, «заблокированные» позже, то есть вызовами EngLockSurface
, выполненными из обратного вызова, не будут присутствовать в отображении.
Когда какой-либо win32kfull.sys!NtGdiEngXXX
получает в качестве параметра указатель SURFOBJ
пользовательского режима, но он не может найти его в отображении, то он предполагает, что полученная запись SURFOBJ
содержится в записи UMSO
(в качестве её поля UMSO.so
).
До патча такие случаи направлялись внутренней функции win32kfull.sys!UMPDSURFOBJ::GetLockedSURFOBJ
, где значение UMSO.magic
сверялось со значением 0x554D534F
, а затем выполнялся вызов EngLockSurface
режима ядра со значением дескриптора UMSO.hsurf
, что, как говорилось выше, позволяло получить нужный указатель на «истинную» SURFOBJ
запись режима ядра.
Как вы могли заметить, имя GetLockedSURFOBJ
неверно, потому что из него можно предположить, что растровое изображение уже заблокировано. На самом деле при поступлении из пользовательского режима эталонный счётчик растрового изображения по-прежнему равен нулю. А как мы видели выше, зловредный драйвер принтера пользовательского режима мог вообще не вызывать EngLockSurface
, а вместо этого просто подготавливать необходимую запись UMSO
с нуля.
После патча имя функции было изменено на GetLockableSURFOBJ
. Драйвер принтера пользовательского режима по-прежнему может выполнять все описанные выше манипуляции, но теперь GetLockableSURFOBJ
считает полученный дескриптор растрового изображения (UMSO.hsurf
) ненадёжным. После использования значения UMSO.hsurf
для блокировки растрового изображения в режиме ядра GetLockableSURFOBJ
теперь ещё раз выполняет ту же валидацию растрового изображения, которая происходила при вызове API EngLockSurface
пользовательского режима. Эта валидация выполняется при помощи вызова win32kfull.sys!IsSurfaceLockable
. Благодаря этому связанные с экраном растровые изображения, которые можно было использовать, чтобы добраться до драйвера дисплея режима ядра из драйвера принтера пользовательского режима, теперь отклоняются GetLockableSURFOBJ
.
Автор:
ru_vds