Пул ядра - это общая столовая всех драйверов системы: любой модуль может отложить в неё кусок памяти на пару байт или на мегабайт, и все едят из одних блюд. Такая щедрость даёт скорость и простоту, но ровно она же превращает переполнение буфера в пуле в одну из самых неприятных категорий уязвимостей ядра. Понять этот класс проблем полезно не только исследователю безопасности: инженеру, пишущему драйверы, и специалисту по реагированию на инциденты знание устройства пулов сильно упрощает жизнь.
Как устроены пулы памяти ядра
Менеджер памяти Windows предоставляет драйверам два фундаментальных бассейна: подкачиваемый пул, страницы которого могут уезжать на диск, и неподкачиваемый пул, чьи страницы гарантированно живут в физической памяти. Неподкачиваемый пул дороже и опаснее: его нельзя трогать на высоких уровнях IRQL - точнее, наоборот, только его и можно трогать на уровнях DISPATCH_LEVEL и выше, потому что обращение к выгруженной странице на таком уровне - немедленная ошибка. Выделение производится через ExAllocatePoolWithTag и её современную родню (ExAllocatePool2 с явными флагами), где тег из четырёх символов служит подписью хозяина: команда отладчика !poolused раскладывает пул по тегам, и утечки вычисляются по ним моментально.
Внутри пул организован как совокупность страниц, нарезанных на блоки. У каждого блока есть заголовок с размером и тегом, блоки одного класса размеров группируются в списки свободных элементов, а мелкие размеры традиционно округляются вверх до границы в шестнадцать байт. На x64 заголовок занимает ровно эти шестнадцать байт, и арифметика блока считается так:
BlockSize в заголовке указан в квантах по 16 байт
полный размер блока = BlockSize * 16
полезная нагрузка = BlockSize * 16 - 16
адрес данных = адрес заголовка + 16
запрос на 100 байт -> квантов нужно ceil((100 + 16) / 16) = 8
реальный блок = 8 * 16 = 128 байт, полезных 112
выход за границу на 13 байт уже портит заголовок соседа
Отсюда практический вывод, который часто игнорируют: запас между запрошенным размером и реальной границей блока небольшой, и переполнение на пару десятков байт почти всегда бьёт по чужому заголовку, а не уходит в пустоту. Со временем механика усложнялась: появились отдельные пулы для сессий, специальные квоты, а в последних версиях - жёсткое разделение между рядовыми выделениями и памятью, где хранятся чувствительные структуры, защищённые изоляцией на базе виртуализации. Эволюция шла не от хорошей жизни: каждое ужесточение - ответ на конкретную серию инцидентов.
Где рождается переполнение и что оно ломает
Причина у переполнения всегда одна и банальна: драйвер записывает в блок пула больше байт, чем выделял. Источники избыточных данных разнообразны: длину пакета взяли из недоверенного запроса приложения без проверки верхней границы; поле размера переполнилось при арифметике, потому что верхнюю границу сравнили уже после того, как число завернулось; строка скопирована без ограничения из соображений "там всегда короткое значение". Вышло наружу хотя бы на байт - и повреждена уже чужая структура: либо заголовок соседнего блока, либо его содержимое.
Почему это так опасно, объясняет само устройство пула. Соседями переполненного буфера оказываются совершенно чужие объекты: таймеры, события, объекты процессов, дескрипторные таблицы, всё, что выделялось из пула в тот же интервал времени. Запись поверх такого соседа меняет указатели функций или служебные поля привилегированных структур. Именно поэтому переполнение в пуле относят к самой тяжёлой категории: последствия определяются не тем, что записал драйвер, а тем, что лежало рядом, и могут варьироваться от обычного падения системы до полной потери контроля над целостностью ядра.
Так называемый heap spray, о котором пишут в обзорах безопасности, - это лишь техника повышения предсказуемости: сторонний код заказывает в пуле множество однотипных блоков, чтобы "положить" свои данные в предсказуемую позицию, куда потенциально укажет повреждённая структура. Механика эта концептуально проста и описана в литературе десятилетие назад, но разбирать её в деталях здесь не станем. Для нас важнее то, чему она учит защитника: детерминизм размещения объектов пула - сам по себе усилитель проблемы, и любые механизмы, ломающие предсказуемость размещения, работают на защиту.
Диагностика переполнений при жизни системы
Главный инструмент обнаружения - Driver Verifier с его механизмом Special Pool. В этом режиме каждое выделение отмеченного драйвера размещается на отдельной странице с недоступной страницей-ловушкой впритык: любая запись за границу мгновенно прошибает защиту страницы, и система останавливается по проверке именно в точке преступления, с кодом ошибки и адресом. Фраза "мгновенно" здесь ключевая: в обычном режиме переполнение проявляется спустя часы, когда пострадавший соседний объект попытаются использовать, и связать падение с виновником уже трудно. Special Pool схлопывает дистанцию между причиной и следствием до нуля, чем и объясняется его репутация главного инструмента при отладке такого класса багов:
verifier /standard /driver mydrv.sys
verifier /volatile /flags 0x1 /adddriver mydrv.sys
verifier /querysettings
verifier /query
gflags /p /enable mydrv.sys /full
gflags /p /enable mydrv.sys /full /backwards 10
verifier /reset
Первый вариант включает стандартный набор проверок на постоянной основе, второй добавляет флаг немедленно, без перезагрузки, что удобно на стенде. Ключ /full у gflags ставит драйвер в специальный пул, а /backwards смещает блок к концу страницы, чтобы запись за границу ловилась на первом же лишнем байте вместо нескольких. Завершает работу verifier /reset, иначе машина продолжит платить памятью за проверки.
Вертикальное дополнение - пуловые квоты и трассировка: включённая обработка pool tracking фиксирует статистику по каждому тегу, а !poolfind и !poolused в отладчике позволяют обойти пул и найти блоки с подозрительными сигнатурами. На стадии расследования аварийного дампа классический ритуал состоит из трёх шагов:
kd> !analyze -v
kd> !pool ffffd10a`12345670 1
kd> !poolfind Drec
kd> !poolused 2
kd> !poolval
kd> dc ffffd10a`12345660 L200
Команда !analyze -v показывает тип остановки, переполнение пула часто выглядит как BAD_POOL_HEADER 0x19 или PAGE_FAULT_IN_NONPAGED_AREA 0x50, !pool с адресом пострадавшей области выводит окрестные блоки с их тегами, а сравнение содержимого позволяет понять, чьи данные затоптали чужой блок. Расширение !poolval прогоняет собственную проверку целостности пула и печатает найденные расхождения списком. Тег чужого тега внутри переполненной области - это как отпечаток ботинка на месте происшествия: имена тегов порой выдают виновный драйвер без единой строчки дизассемблера.
Архитектурные меры защиты от этого класса проблем
Борьба с переполнениями пула идёт на разных этажах, и разумно выстроить защиту эшелонированно:
- Валидация размеров на границе недоверенных данных - любое число из пользовательского запроса проверяется на диапазон и на целочисленное переполнение до арифметики с ним;
- Использование безопасных арифметических помощников (RtlULongAdd, RtlSizeTMult и им подобных из библиотеки безопасных целых), которые отказываются вычислять заворачивающиеся размеры;
- Копирование фиксированного назначения через функции с контролем длины и явной привязкой к реальному размеру целевого буфера, который хранится рядом с указателем;
- Периодический прогон драйвера под Special Pool и полным набором проверок верификатора в CI, а не по праздникам;
- Отказ от самодельных аллокаторов поверх пула ради выдуманной эффективности: слои обёрток искажают границы блоков и маскируют границы доступа от проверяющих инструментов.
Первый и третий пункты на практике сводятся к двум коротким функциям, которые стоят на входе любого обработчика запроса:
NTSTATUS ValidateRequest(PMY_REQUEST req, SIZE_T* TotalBytes)
{
ULONG64 total = 0;
// верхняя граница проверяется до умножения, а не после
if (req->Count == 0 || req->Count > MY_MAX_RECORDS) {
return STATUS_INVALID_PARAMETER;
}
if (!NT_SUCCESS(RtlULongLongMult(req->Count, sizeof(MY_RECORD), &total))) {
return STATUS_INTEGER_OVERFLOW;
}
if (total > MY_MAX_ALLOCATION) {
return STATUS_INVALID_PARAMETER;
}
*TotalBytes = (SIZE_T)total;
return STATUS_SUCCESS;
}
NTSTATUS CopySafely(PBUFFER buf, PVOID src, ULONG srcLen)
{
if (srcLen > buf->Size) {
return STATUS_BUFFER_TOO_SMALL;
}
RtlCopyMemory(buf->Data, src, srcLen);
return STATUS_SUCCESS;
}
Размер целевого буфера хранят рядом с указателем и сверяют с ним каждое копирование, а не полагаются на то, что вызывающий передал верную длину.
Со стороны платформы добавляются системные барьеры: изоляция чувствительных структур в защищённую виртуализацией память (компонент VBS), рандомизация размещения объектов, подпись драйверов как фильтр на входе. Ни одна из этих мер не заменяет корректный код, но в совокупности они здорово повышают цену ошибки для того, кто попытается превратить банальное переполнение в системную проблему.
Экономика пула и почему неподкачиваемый бассейн особенно чувствителен
Разговор о переполнениях неотделим от экономики самих пулов, и один её аспект стоит проговорить отдельно. Неподкачиваемый пул - ресурс конечный и в старых 32-разрядных системах совсем крошечный: исторический лимит измерялся сотнями мегабайт, и одержимый драйвер мог исчерпать его сам по себе. В 64-разрядной эпохе потолки выросли настолько, что исчерпание пула чистым объёмом маловероятно, но сам факт неподкачиваемости усиливает цену каждой повреждённой страницы: порча обнаруживается немедленно на активном коде, а не откладывается до подкачки. Отсюда практический принцип, который вбивают в голову каждому новичку в команде разработчиков драйверов: неподкачиваемый пул берут только тогда, когда это требуется по уровню IRQL или синхронизации, всё остальное живёт в подкачиваемом.
Вторая экономическая тема - фрагментация. Бесконечная карусель мелких выделений и освобождений с разрозненными размерами превращает пул в швейцарский сыр, где найти большой непрерывный блок всё труднее. Зрелые драйверы используют lookaside-списки - кэши однотипных объектов фиксированного размера, инициализируемые через ExInitializeLookasideListEx: повторное выделение оттуда мгновенно, а нагрузка на общий пул минимальна.
static EX_LOOKASIDE_LIST_EX g_RecordLookaside;
NTSTATUS InitLookaside(VOID)
{
return ExInitializeLookasideListEx(&g_RecordLookaside, NULL, NULL,
0, 0, sizeof(MY_RECORD), 'cerD', 0);
}
PMY_RECORD AcquireRecord(VOID)
{
return (PMY_RECORD)ExAllocateFromLookasideListEx(&g_RecordLookaside);
}
VOID ReleaseRecord(PMY_RECORD rec)
{
ExFreeToLookasideListEx(&g_RecordLookaside, rec);
}
VOID DestroyLookaside(VOID)
{
ExDeleteLookasideListEx(&g_RecordLookaside);
}
Параметр типа пула в ExInitializeLookasideListEx обязательно передают равным нулю, иначе проверка параметров не пропустит вызов. Тег cerD в записи читается как Drec, то есть обратная запись метки, и именно его потом ищут в !poolused. Заодно lookaside упорядочивает размещение, что побочно снижает и предсказуемость раскладки, о которой шла речь выше.
Теги пула как инструмент зрелого владения памятью
Зрелые команды доводят работу с тегами до автоматизма. Каждый логический тип выделения получает собственный четырёхсимвольный тег, теги документируются в справочнике проекта, а регрессионный скрипт после каждого нагрузочного прогона сверяет !poolused с эталоном. Разница между "выделили и освободили" и "выделили и съели" в такой таблице видна мгновенно: тег с нарастающим счётчиком блоков кричит об утечке. Эта простая дисциплина решает сразу две задачи: утечки ловятся на стенде, а расследование чужих переполнений упрощается, потому что пострадавшие блоки легко сопоставить с их хозяевами.
Дополняет картину использование расширенных проверок верификатора, отслеживающих освобождение занятого блока, двойное освобождение и запись в освобождённую память. Последний пункт - близкий родственник переполнения: обращение к уже возвращённому в пул блоку портит следующего жильца этой памяти, и симптомы наружу выходят идентичные. Поэтому правило аудита звучит шире: разбирается не только класс "записал слишком много", но и класс "записал слишком поздно".
Перед тем как перейти к реагированию, полезно закрепить и смежный навык - чтение статистики пулов во времени. Команда !poolused с ключом сортировки по числу выделений, снятая через равные промежутки на нагруженном стенде, превращается в кардиограмму системы:
kd> !poolused 2
Pool Tracking Overview: NonPaged
Tag Allocs Frees Diff Bytes/Allocs
Drec 4711 4711 0 75376
FMb 18344 17980 364 1436672
Ровные теги дышат спокойно, колонка Diff у них около нуля, а тег с растущим трендом блоков в неподкачиваемом пуле диагностирует утечку задолго до жалоб. Тот же вывод на живой системе снимает утилита poolmon из комплекта отладочных инструментов: