
CVE-2026-50416: обход KASLR в Windows 11
В предварительной сборке Windows 11 10.0.28020.2149 отображение кучи рабочего стола win32k в пользовательском режиме выдаёт сырой указатель на пул сеанса ядра по смещению 0x100. Любой процесс, имеющий рабочий стол, может прочитать его. Это касается процессов с низким уровнем целостности, AppContainer, LPAC без возможностей и низкоцелостностного AppContainer, который выглядит в точности как рендерер браузера. Из одного этого утёкшего QWORD можно восстановить базу кучи рабочего стола ядра, а оттуда — с помощью gSharedInfo — вычислить точный виртуальный адрес ядра каждого окна, меню и объекта класса на рабочем столе.
Это обход KASLR, раскрытие информации. Сам по себе это не выполнение кода. Я хочу сказать это сразу, потому что переоценить баг — самый быстрый способ сделать разбор нечитаемым. На самом деле это очень чистая утечка из мест, которых она действительно не должна достигать.
CVE-2026-50416. В этом посте описан баг, доказательство концепции и та часть, которая заставила меня всерьёз насторожиться: границы песочницы, сквозь которые остаётся доступным указатель ядра.
Каждый процесс, подключающийся к рабочему столу, получает отображение кучи рабочего стола только для чтения в своём адресном пространстве. Ядро записывает объекты окон и меню в эту кучу, а пользовательский режим считывает их — это нормально и необходимо. Совсем не обязательно, чтобы по смещению 0x100 этого отображения находился живой указатель ядра на пул сеанса. Никто не санирует его до того, как он становится видимым пользовательскому режиму. Windows уже умеет санировать указатели ядра в этой куче: для других полей используется сигнатура 0x6000000000. Это поле просто пропустили.
win32k — это компонент ядра, которому принадлежат окна, меню, курсоры, хуки, весь мир графических объектов. Значительная часть состояния win32k живёт на каждом рабочем столе в структуре, называемой кучей рабочего стола (desktop heap). Чтобы операции с окнами были быстрыми, ядро отображает часть этой кучи в каждый процесс, подключающийся к рабочему столу, в виде общего раздела только для чтения. Ваш процесс читает текст или стиль окна прямо из этого отображения без системного вызова. Это отображение — общая иллюзия, которая делает графический интерфейс мгновенным.
Найти её из пользовательского режима тривиально и задокументировано. В TEB есть структура ClientInfo. Адрес кучи рабочего стола в пользовательском режиме лежит в ClientInfo[5]. В коде:
PVOID teb = (PVOID)__readgsqword(0x30);
PVOID* clientInfo = (PVOID*)((BYTE*)teb + 0x800);
BYTE* desktopHeap = (BYTE*)clientInfo[5];
Это весь дескриптор. Пока никакой уязвимости. Эта часть предусмотрена архитектурой.
Уязвимость — это то, что находится по смещению 0x100 этой кучи.
ULONG64 leaked = *(ULONG64*)(desktopHeap + 0x100);
В протестированной сборке это QWORD содержит что-то вроде 0xFFFFA804DDE00040. Это канонический адрес ядра. Он выровнен по восьми байтам. Он указывает в пул сеанса, на 0x40 байт вглубь стороны ядра этой самой кучи рабочего стола.
Прежде чем поверить в это, я провёл обычные проверки на вменяемость.
Итак, это настоящий, постоянный на протяжении всей загрузки адрес ядра, а не мусор и не устаревшее значение. Свойства четыре и пять вместе — это отпечаток адреса, рандомизированного KASLR, который постоянен в пределах одной загрузки. Именно это KASLR и должен скрывать.
Для любопытных: значение в моей реальной тестовой сессии было 0xFFFFC600DCC00040. Та же форма, то же поведение. У вас оно будет другим из-за KASLR. В этом, собственно, и суть.
Один утёкший адрес — это хорошо. Но по-настоящему полезным становится превращение его в адрес любого конкретного объекта.
Работают два факта.
Первый: утёкшее значение указывает на 0x40 байт внутрь кучи рабочего стола ядра. Стало быть, база кучи рабочего стола ядра — это утёкшее значение минус 0x40.
kernel desktop heap base = leaked minus 0x40
Второй: user32 экспортирует структуру gSharedInfo. Помимо прочего она даёт вам глобальную таблицу дескрипторов (aheList) и размер одной записи таблицы дескрипторов (HeEntrySize, 0x20 на x64). Каждый HWND по сути является индексом в этой таблице. Возьмите младшие 16 бит HWND, перейдите к этой записи, и первый QWORD записи — это смещение объекта окна внутри кучи рабочего стола.
Сложите их вместе:
offset = gSharedInfo.aheList[HWND & 0xFFFF].offset
kernel addr = kernel desktop heap base plus offset
Это точный виртуальный адрес ядра объекта окна, стоящего за данным HWND. Не догадка. Не распыление. Фактический адрес.
Доказательство концепции создаёт шесть окон разных классов (STATIC, BUTTON, EDIT, LISTBOX, SCROLLBAR, COMBOBOX) и вычисляет адреса всех шести. Оно также сканирует кучу и находит ещё полдюжины несанированных указателей ядра за пределами 0x100, так что 0x100 — просто самый надёжный из них, но не единственный.
Вот часть, которая перевела это из разряда интересного в разряд по-настоящему тревожного.
Я написал вторую программу, которая порождает дочерние процессы во всё более ограниченных контекстах и спрашивает каждый из них, может ли он прочитать тот же указатель. Контексты, в порядке возрастания ограниченности их возможностей:
CreateDesktop. Утекает, но с другим значением, потому что у него своя куча рабочего стола.Все контексты с первого по пятый вернули один и тот же адрес ядра, потому что они используют общую кучу рабочего стола по умолчанию. Контекст шесть вернул другой адрес, потому что это другая куча, но метод сработал идентично.
Пятый — тот, на который стоит пристально смотреть. Процесс, работающий с низкой целостностью внутри AppContainer, обладающий нулевыми возможностями, — это примерно максимальная изоляция, доступная процессу пользовательского режима в Windows. Именно в такую песочницу браузер помещает свой рендерер. Эта песочница может прочитать адрес ядра.
Есть причина, по которой это задевает за живое. Вся модель многоуровневой защиты браузеров предполагает, что даже если атакующий получит выполнение кода внутри рендерера через отдельный баг в движке, песочница сдержит ущерб и скроет ядро. KASLR — большая часть этого скрытия. Эта утечка проникает внутрь песочницы и передаёт атакующему адрес ядра без дополнительного системного вызова и без повышения привилегий. Песочница сработала идеально. Информация всё равно утекла из-под неё — через общий раздел, который модель песочницы считает безвредным.
Я всё ждал подвоха. Наверняка нужно сначала создать окно или вызвать какой-нибудь GUI-системный вызов, который песочница заметит. Нет.
В этой сборке отображение кучи рабочего стола уже присутствует в адресном пространстве процесса до загрузки user32.dll. Проверочный код читает desktop_heap[0x100] до и после загрузки user32, и оба чтения возвращают один и тот же указатель ядра. Никакого CreateWindow. Никакого GetDesktopWindow. Ничего. Любой процесс, связанный с рабочим столом, включая фоновые службы без интерфейса и фоновые работники, может прочитать его сразу после запуска.
Доказательство для рендерера опирается на это. Оно запускает дочерний процесс с низкой целостностью в AppContainer без возможностей, заставляет его загрузить только user32, и он читает указатель сразу при старте. Вывод дословно:
RENDERER|LEAKED|0xffffc600dcc00040|AC=1|IL=0x1000|NoWindowCreated
IL=0x1000 — это низкая целостность. AC=1 — внутри AppContainer. NoWindowCreated означает ровно то, что звучит: окно не создавалось.
Пока я там копался, я навёл сканер на кучу, чтобы посмотреть, что ещё там валяется. Это не только указатели.
Заголовки окон других процессов. Сканер нашёл двадцать уникальных заголовков от других процессов, лежащих в куче в UTF16; каждый был сверен с EnumWindows, чтобы подтвердить, что владеющий PID действительно владел окном с таким заголовком. Вкладки Chrome, Discord, Explorer, Spotify, окна в трее. Куча — это маленькая доска объявлений о том, чем занят каждый на рабочем столе, и любой процесс в песочнице может прочитать её без единого IPC.
Идентификаторы процессов. Более шестисот значений DWORD в куче были проверены как реальные работающие PID, каждое подтверждено двумя способами: успешным OpenProcess и появлением PID в списке EnumWindows. Так что процесс в песочнице может молча перечислить, у кого на рабочем столе есть окна.
Больше указателей ядра. От шести до десяти за запуск, в зависимости от активности рабочего стола, а не только один по адресу 0x100.
Чего оно НЕ раскрывает — я это специально проверил — так это текст полей ввода пароля. Я создал EDIT с ES_PASSWORD, задал его текстом SecretPassword123 и обыскал кучу. Его там нет. Windows маскирует содержимое полей пароля в куче рабочего стола. Хорошо. Маленькая милость.
Итак, куча утекает адресами и заголовками окон, но хотя бы пароли хранит. Беру победу там, где могу её найти.
KASLR существует, чтобы сделать эксплуатацию ядра вероятностной. Без знания адресов use-after-free в win32k превращается в слепое распыление в пуле. Вы заливаете аллокатор ядра, надеетесь, что ваш объект-замена окажется там, куда указывает висячий указатель, и молитесь. В современных реализациях пула этот процент успеха низок и становится всё ниже.
Теперь дайте атакующему эту утечку. Он знает точный адрес ядра освобождённого объекта окна. Он может создать заменяющее выделение ровно по этому адресу и точно контролировать, на что ссылается висячий указатель. Слепое повреждение становится детерминированным. В этом настоящая ценность чистого обхода KASLR: это множитель надёжности для несвязанного бага повреждения памяти, а не самостоятельный баг.
Конкретно, вот какие меры смягчения эта утечка подрывает:
gSharedInfo, чтобы узнать точные смещения каждого объекта окна, а затем вычисляет адреса ядра напрямую.Объекты, чей адрес ядра получается из этого единственного чтения: объекты окон (tagWND), объекты меню (tagMENU), объекты классов (tagCLS) и всё остальное в куче рабочего стола, достижимое через таблицу дескрипторов. Одно чтение — весь рабочий стол на карте.
В каталоге PoC есть compile.bat, который находит Visual Studio и собирает выбранную вами цель.
compile.bat then choose a number from the menu
Цели, в порядке того, насколько много они вам показывают:
kaslr_bypass_poc.exe. Главная. Проходит по шагам утечку, вычисляет базу ядра, определяет адреса живых окон, сканирует дополнительные указатели и выводит всю цепочку. Запускайте первой.kaslr_sandbox_proof.exe. Запускает дочерние процессы с низким уровнем целостности (Low IL), в AppContainer, в LPAC, с Low IL плюс AppContainer и на альтернативном рабочем столе, затем сообщает, утёк ли каждый и согласуются ли они между собой.supporting_proof_no_window.exe. Доказывает, что утечка работает до создания любого окна.supporting_proof_no_caps_lpac.exe. Доказывает её из AppContainer и LPAC с нулевыми возможностями.supporting_proof_sensitive_data.exe. Доказывает раскрытие межпроцессных данных — заголовков и PID — и то, что текст паролей не раскрывается.supporting_proof_exploitability.exe. Вычисляет адреса ядра для шести классов окон и показывает поражение мер смягчения.supporting_proof_remote_trigger.exe. Контекст, эквивалентный рендереру.Три быстрых проверки, чтобы убедиться, что это реально:
Первое и второе подтверждают, что это стабильный, общий, реальный адрес. Третье подтверждает, что он рандомизирован KASLR. Вместе они и есть баг.
Ожидалось. Отображение кучи рабочего стола в пользовательском режиме никогда не должно раскрывать сырые указатели ядра. Любой указатель ядра в метаданных кучи рабочего стола должен санироваться до того, как его увидит пользовательский режим.
Фактически. По смещению 0x100 раскрывается указатель на пул сеанса ядра. Его читает каждый процесс с рабочим столом, включая процессы с низкой целостностью, AppContainer и LPAC. В протестированной сессии каждый контекст вернул 0xFFFFC600DCC00040.
Самое дешёвое исправление повторяет то, что Windows уже делает в других местах этой кучи. Санировать указатели ядра в заголовке кучи рабочего стола до того, как они попадут в отображение пользовательского режима, — та же обработка сигнатурой 0x6000000000, что используется для других полей.
Более сильное исправление, если пользовательскому режиму на самом деле не нужна страница заголовка, — вообще перестать раскрывать эту страницу через отображение пользовательского режима. У меня нет сильного мнения о том, какое из них выберет Microsoft. У меня есть сильное мнение, что сырой указатель ядра не должен находиться на расстоянии одного __readgsqword и одного разыменования указателя от рендерера без возможностей.
Я постоянно возвращаюсь к контексту пять. Низкоцелостностный AppContainer без возможностей должен быть коробкой, из которой ничто интересное не сбегает. Отображение кучи рабочего стола — это тот тип общего ресурса, который модель песочницы давно решила оставлять отображённым, потому что он доступен только для чтения и потому что чтение текста окон безвредно. Оба этих факта по-прежнему верны. Изменилось то, что одно поле заголовка превратило это безвредное окно только для чтения в глазок прямо в пул сеанса.
Песочница не подвела. Подвело предположение, лежащее в основе песочницы. Это разные сбои, и второй труднее предвидеть — возможно, поэтому его никто и не заметил. Пока вы не прочитаете смещение 0x100.