
Анализ первопричины CVE-2026-54107: use-after-free в Windows win32kfull.sys с отладкой условий гонки, статическим анализом, аналитикой триажа MSRC и практическими исследованиями эксплуатации ядра.
ValidateHwnd — не ворота: поиск первопричины CVE-2026-54107Use-after-free в
win32kfull.sysпри управлении жизненным циклом окон — как я его нашёл, как убедил себя, что он реален, и как на самом деле выглядел процесс MSRC со стороны исследователя.
Было почти 2 часа ночи, когда целевая VM перестала отвечать на heartbeat отладчика и остановилась на break ровно на той инструкции, о достижимости которой я спорил неделями. Не assertion, не остановка из-за повреждённого пула — обычное нарушение доступа на пути диспетчеризации сообщений, при разыменовании объекта, который другой поток уже уничтожил.
Этот break стал CVE-2026-54107, делом MSRC 11xxxxx, исправленным в обновлении безопасности за июль 2026 года для 27 продуктов Windows.
Этот пост — не попавшая под эмбарго половина истории: первопричина, почему класс ошибки именно такой, и рассуждения, которые привели меня к ней. Детали эксплуатации остаются за кадром.
Win32k — это работающая в режиме ядра половина графической подсистемы Windows. Она старая, она огромная и — что критически важно — она достижима из контекстов, которые считаются недоверенными. Именно последнее свойство делает её постоянной целью исследований, несмотря на двадцать лет работ по ужесточению, фильтрации и ограничению системных вызовов.
Внутри win32k объект tagWND (PWND) необычайно интересен тем, что его временем жизни управляет сразу несколько механизмов. Окно:
ValidateHwnd,Любой объект с несколькими независимыми путями ссылок и одним общим путём разрушения стоит читать медленно. Это не заявление об уязвимости — это эвристика того, где стоит провести время.
Что заставило меня засесть за этот компонент, так это поверхность импорта. win32kfull.sys тянет из ntoskrnl три различных примитива ссылок на объекты:```c
NTSTATUS ObReferenceObjectByPointer(
void *Object,
uint32_t DesiredAccess,
POBJECT_TYPE ObjectType,
char AccessMode);
NTSTATUS ObReferenceObjectByHandle( HANDLE Handle, uint32_t DesiredAccess, POBJECT_TYPE ObjectType, char AccessMode, void **Object, OBJECT_HANDLE_INFORMATION *HandleInformation);
NTSTATUS ObReferenceObjectByName(/* ... */);
Три пути входа, один путь выхода через `ObfDereferenceObject`.
Это не значит, что код неправильный. Это значит, что **инвариант распределён** — ни одна отдельная функция не владеет утверждением *«этот объект сейчас жив»*, поэтому корректность зависит от того, что каждый вызывающий код согласованно понимает, какой ссылкой он владеет и как долго эта ссылка действительна. Распределённые инварианты — это среда обитания состояний гонки, потому что гонка никогда не является логической ошибкой, которую можно увидеть в одной функции. Это ошибка в предположении, разделяемом между двумя.
Поэтому вопрос, который я начал задавать каждой функции, касающейся `PWND`, был не *«корректен ли этот код?»*, а:
> **Если это конкретное тело функции выполняется на двух потоках с интервалом в несколько инструкций, какой из них ошибочен?**
## 3. Корневая причина
Дефект представляет собой **разрыв между проверкой и использованием (time-of-check / time-of-use) между освобождением ссылки и уничтожением объекта** в пути уничтожения окна, без надлежащей синхронизации с параллельным потребителем, проверяющим валидность дескриптора.
Если свести к сути:```c
/* Thread A — teardown */
NtUserDestroyWindow(HWND hwnd)
{
PWND pWnd = ValidateHwnd(hwnd);
if (pWnd) {
ObfDereferenceObject(pWnd); /* reference released */
/* <-- race window: object may become reclaimable here */
FreeWindowObject(pWnd); /* teardown proceeds on a pointer
no longer guaranteed live */
}
}
Входной фрагмент пуст — переводить нечего.```c /* Thread B — consumer, concurrent / NtUserMessageCall(HWND hwnd, UINT msg, ...) { PWND pWnd = ValidateHwnd(hwnd); / may resolve a handle whose object is mid-teardown */
if (pWnd->fnid == FNID_BUTTON) /* use-after-free */
...
}
Чтобы это имело значение, должны выполняться два условия, и оба выполнялись:
**(a) Окно реально.** `ValidateHwnd` — это шлюз, который должен обеспечивать безопасность доступа на основе дескрипторов. Если проверка может успешно пройти для объекта, уничтожение которого уже началось, то это не шлюз, а рекомендация.
**(b) Освобождённая память управляема атакующим.** Поля, читаемые сразу после проверки, включают `fnid`, который управляет диспетчеризацией сообщений. Решение о диспетчеризации, принятое на основе переиспользованной памяти, — это разница между *«ненадёжным крахом»* и *«нарушением границы безопасности»*. Именно это различие — вся причина, по которой это CWE-362 с воздействием EoP, а не баг стабильности.
> Наблюдаемое **повреждение** — это use-after-free; **причина** — CWE-362, параллельное выполнение с использованием общего ресурса без надлежащей синхронизации. Это два разных утверждения, и MSRC интересует второе. **Сообщайте о причине, а не только о симптоме.**
### Почему гонки в win32k структурно сложнее, чем кажутся
Если вы охотились за гонками в других подсистемах, win32k вас разочарует: архитектура противодействует вам тремя конкретными способами.
**Окна имеют привязку к потоку.** Окно принадлежит потоку, который его создал. Значительная часть подсистемы построена на предположении, что владеющий поток — это тот, кто обращается к объекту, а это означает, что наивный подход «запустить два потока, вызывающих один и тот же API», часто ничего не пересекает — вы не создаёте гонку, а выстраиваетесь в очередь. Чтобы два пути действительно столкнулись на одном объекте, нужно понимать, какие операции реально выполняются в потоке вызывающего, а какие маршализуются в поток владельца.