Пул ядра - это общая столовая всех драйверов системы: любой модуль может отложить в неё кусок памяти на пару байт или на мегабайт, и все едят из одних блюд. Такая щедрость даёт скорость и простоту, но ровно она же превращает переполнение буфера в пуле в одну из самых неприятных категорий уязвимостей ядра. Понять этот класс проблем полезно не только исследователю безопасности: инженеру, пишущему драйверы, и специалисту по реагированию на инциденты знание устройства пулов сильно упрощает жизнь.

Как устроены пулы памяти ядра

Менеджер памяти 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 прогоняет собственную проверку целостности пула и печатает найденные расхождения списком. Тег чужого тега внутри переполненной области - это как отпечаток ботинка на месте происшествия: имена тегов порой выдают виновный драйвер без единой строчки дизассемблера.

Архитектурные меры защиты от этого класса проблем

Борьба с переполнениями пула идёт на разных этажах, и разумно выстроить защиту эшелонированно:

  1. Валидация размеров на границе недоверенных данных - любое число из пользовательского запроса проверяется на диапазон и на целочисленное переполнение до арифметики с ним;
  2. Использование безопасных арифметических помощников (RtlULongAdd, RtlSizeTMult и им подобных из библиотеки безопасных целых), которые отказываются вычислять заворачивающиеся размеры;
  3. Копирование фиксированного назначения через функции с контролем длины и явной привязкой к реальному размеру целевого буфера, который хранится рядом с указателем;
  4. Периодический прогон драйвера под Special Pool и полным набором проверок верификатора в CI, а не по праздникам;
  5. Отказ от самодельных аллокаторов поверх пула ради выдуманной эффективности: слои обёрток искажают границы блоков и маскируют границы доступа от проверяющих инструментов.

Первый и третий пункты на практике сводятся к двум коротким функциям, которые стоят на входе любого обработчика запроса:

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 из комплекта отладочных инструментов:

CODE6 Подобные графики давно вошли в обязательный инструментарий тестировщиков драйверов: любой прогон на выносливость завершается сверкой "воды в бассейне" до и после, и отклонение больше статистической погрешности расценивается как дефект, даже если функциональные тесты зазеленели. Эта дисциплина надёжно страхует от самого неприятного класса проблем - медленных утечек, которые не падают громко, а неделями подтачивают запас памяти, пока сервер не начинает внезапно задыхаться в час пик.

Стоит упомянуть и о пользе регулярных внутренних разборов: команда, которая раз в квартал устраивает совместный просмотр свежих дампов и учит младших коллег отличать критическую порчу пула от случайной шалости, выращивает наследование опыта, без которого диагностика такого класса остаётся уделом избранных вместо общей дисциплины всех причастных.

Что делать, если переполнение уже случилось и как закрыть весь класс ошибок

Для инженера, обнаружившего переполнение в своём драйвере, последовательность действий устоялась. Сначала баг воспроизводится под Special Pool на стенде: точное место записи за границу находится за один проход, а не за неделю созерцания дампов. Затем проводится аудит всего класса источников данных драйвера: переполнения в пуле редко бывают одиноки, и найденный экземпляр указывает на системную слабость в обработке входных размеров. Далее исправление покрывается тестом, который гарантированно гоняется под верификатором, чтобы регрессия сработала автоматически.

Для специалиста по безопасности переполнение пула в чужом драйвере - сигнал к комплексной оценке: важно понять, достижим ли путь до точки переполнения из пользовательского режима, какие объекты типично размещаются рядом и какие меры платформы стоят на пути. Ответы на эти вопросы определяют реальную критичность находки и состав отчёта. И завершает цикл самое ценное: знание этого класса уязвимостей меняет почерк разработчика раз и навсегда - после пары ночей с !pool и Special Pool любой размер из внешнего запроса встречается здоровым подозрением ещё до первой компиляции.