Синий экран с кодом 0x19 BAD_POOL_HEADER или 0xC2 BAD_POOL_CALLER приходит ночью, на сервере, где никто не сидел за консолью. К утру в папке Minidump или рядом лежит MEMORY.DMP, и перед инженером встаёт классическая задача post-mortem анализа: по остывшему слепку памяти понять, кто испортил заголовок пула, когда это случилось и какой драйвер за это ответит объяснительной. Задача напоминает работу эксперта на месте происшествия, где свидетелей нет, а улики - только четыре параметра bug check и несколько мегабайт содержимого невидимых страниц. Разберём, как ведётся такое расследование в WinDbg и какие приёмы выводят на настоящего виновника даже тогда, когда самого акта порчи в дампе уже нет.

Что фиксирует ядро в момент обнаружения порчи пула

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

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

Arg1 = 0x2   Arg2 проверяемая запись, Arg3 размер блока
             провал проверки шаблона special pool, владелец сам испортил блок
Arg1 = 0x3   Arg2 запись пула, Arg3 прочитанный flink, Arg4 прочитанный blink
             повреждён список свободных; в здоровом списке все три значения совпадают
Arg1 = 0x5   Arg2 и Arg4 две соседние записи
             заголовки соседних блоков противоречат друг другу, испорчен минимум один
Arg1 = 0x6   Arg2 неверно вычисленная запись, Arg4 источник просчёта
             поле PreviousSize слишком большое
Arg1 = 0x7   Arg4 плохая запись, размер блока в заголовке испорчен
Arg1 = 0x8   Arg4 плохая запись, размер блока в заголовке равен нулю
Arg1 = 0x9   как 0x6, размер блока испорчен и слишком большой
Arg1 = 0xA   Arg2 ожидаемая запись, Arg4 адрес страницы, где она должна была лежать
Arg1 = 0xD, 0xE, 0xF, 0x23, 0x24, 0x25
             заголовок освобождённого блока изменён уже после освобождения;
             обычно это не вина прежнего владельца, а переполнение блока перед ним
Arg1 = 0x20  Arg2 ожидаемая запись, Arg3 следующая запись, размер блока испорчен
Arg1 = 0x21  Arg2 освобождаемый указатель, Arg3 число выделенных байт,
             Arg4 испорченное значение сразу за блоком; типичный выход за границу
Arg1 = 0x22  Arg2 освобождаемый адрес, у которого нет записи сопровождения;
             повторное освобождение либо освобождение никогда не выделявшегося адреса

Отдельно стоит запомнить формулировку причины из документации: на момент текущего запроса пул уже был повреждён, и текущий вызывающий может быть ни при чём. Именно поэтому значения 0xD, 0xE и 0x25 чаще всего указывают не на владельца блока, а на его соседа слева.

Для 0xC2 BAD_POOL_CALLER вводные похожие: попытка освободить адрес вне пула, неверный IRQL при обращении к подкачиваемому пулу, двойное освобождение. Каждый код сужает круг подозреваемых: двойное освобождение ищется в логике владения, порча заголовка - в переполнении буферов, неверный IRQL - в DPC, которые потянулись к выгружаемой памяти.

Первым делом в дампе исполняют !analyze -v, команду, которая даёт короткое резюме и гипотезу модуля:

kd> !analyze -v
BAD_POOL_HEADER (19)
Arguments:
Arg1: 0000000000000021, The data following the pool block being freed is corrupt.
Arg2: ffffd10a`12345670, The pool pointer being freed.
Arg3: 0000000000000080, The number of bytes allocated for the pool block.
Arg4: 4141414141414141, The corrupted value found following the pool block.
IMAGE_NAME:  myfilter.sys
MODULE_NAME: myfilter
FAILURE_BUCKET_ID: 0x19_21_myfilter.sys!FltpPerformPreCompletion

kd> dc ffffd10a`12345670 L40
kd> dq ffffd10a`12345670 L8
kd> !pool ffffd10a`12345670 1

Доверять эвристике слепо нельзя: она складывает вину на модуль из вершины стека исполнителя, который чаще всего ни при чём, а значение Arg4 в этом примере вообще равно повторяющемуся символу, то есть писали строкой. Настоящая работа начинается с верификации повреждённой области командами dc, dq и !pool по адресу из второго параметра. Если !pool говорит, что страница не принадлежит пулу, или тег блока не совпадает с ожидаемым, появляется первая опора расследования.

Чтение анатомии блока пула вручную

Блок ядерного пула состоит из заголовка и полезной нагрузки. В 64-разрядных системах заголовок POOL_HEADER занимает 16 байт и содержит размер блока в 16-байтных квантах, тип пула и четырёхбайтовый тег, который драйвер передал в ExAllocatePoolWithTag:

kd> dt nt!_POOL_HEADER ffffd10a`12345660
   +0x000 PreviousSize     : 0y000000000 (0x0)
   +0x000 PoolIndex        : 0y000000000 (0x0)
   +0x000 BlockSize        : 0y00000001001 (0x9)
   +0x000 PoolType         : 0y0000000000000001 (0x1)
   +0x008 PoolTag          : 0x67617246
   +0x00c AllocatorBackTraceIndex : 0
   +0x00e PoolTagHash      : 0

kd> db ffffd10a`12345668 L8
ffffd10a`12345668  46 72 61 67 00 00 00 00   Frag....

Размер в байтах пересчитывается из квантов одной формулой, и её полезно держать в голове, чтобы отличить правдоподобный блок от мусора:

BlockSize указан в квантах по 16 байт
полный размер блока   = BlockSize * 16 = 9 * 16 = 144 байта
полезная нагрузка     = BlockSize * 16 - 16 = 128 байт
адрес начала блока    = адрес заголовка
адрес данных          = адрес заголовка + 16

Тег читается как четыре ASCII-символа в обратном порядке байт: 0x67617246 это строка Frag. Знание тега - полдела, потому что по нему ищется драйвер-аллокатор: каждый уважающий себя модуль использует уникальную четырёхбуквенную метку.

Когда заголовок повреждён, тег может быть искажён, но и это полезная зацепка. Надпись вроде 'raPr' вместо легального 'Frag' говорит о перезаписи текстовыми данными, а значит, виновник, скорее всего, копировал строку без учёта длины. Нули размером в несколько сотен байт подряд намекают на memset по неверной границе. Паттерн 0xAB повторяющийся через каждые 16 байт - классический след Special Pool при заполнении страницы. Каждый узор памяти рассказывает свою историю, и опытный аналитик читает их как оттиски подошв.

Поиск по тегу и раскладку пула по драйверам дают три команды:

kd> !poolfind Frag
kd> !poolused 2
kd> !poolused 2 Frag
kd> !verifier 0x80 myfilter.sys

Команда !poolfind прощупывает пул на предмет живых блоков с этой пометкой, и по распределению живых блоков реконструируется карта: где были соседи, какой драйвер выделял сколько и какого размера. Команда !poolused с двойкой сортирует вывод по объёму неподкачиваемого пула, а с указанием тега сужает отчёт до одной метки. Расширение !verifier показывает учёт выделений по драйверу, но только если сопровождение пула было включено заранее.

В продвинутых случаях pool tracking включают через верификатор ещё до воспроизведения, и тогда в дампе становится доступен точный стек того, кто выделял блок, и того, кто освобождал его в прошлый раз. Ничего похожего на это в простом аварийном дампе нет, поэтому верификатор настраивают заранее на тестовых стендах.

Отдельные сценарии порчи и их узнаваемые почерки

На каждый из классических видов повреждения пула приходится свой почерк, и научиться их различать - значит сократить расследование с дней до часов. Основные истории, которые инженер встречает в дампах чаще всего, выглядят так:

  1. Переполнение буфера при копировании строки или структуры: соседний заголовок затирается данными, которые по содержимому узнаются как путь к файлу, сетевой пакет или JSON, и автор ищется через поиск этого содержимого в образе драйвера;
  2. Use after free: блок после освобождения помечается менеджером пула специальной сигнатурой, а попытка повторного освобождения ловится как параметр 3 в коде 0x19, при этом кто писал в уже мёртвую память обнаруживается через pool tracking;
  3. Выход за границу при работе с DMA: сетевой или дисковый драйвер отдал устройству адрес буфера без ScatterGatherList, устройство записало ровно на размер пакета больше, и повреждение пула случилось в ячейках, которые писал вообще не процессор;
  4. Неверный тег и двойное владение: два разных пути кода считают себя хозяевами блока и оба зовут ExFreePoolWithTag, что ловится по истории pool tracking как два стека освобождения подряд.

Каждый из этих почерков требует своего массива доказательств, поэтому инженер на месте дампа не торопится закрывать дело по первому подозреваемому. Он собирает версию, подтверждает её независимым источником и только потом идёт читать код конкретного драйвера.

Настройка символов и чтение внутренних структур менеджера пула

Ни одно серьёзное расследование не начинается без правильных символов. Отладчику требуются PDB-файлы ядра и библиотек HAL, которые подтягиваются с сервера символов:

kd> .symfix C:\Symbols
kd> .reload /f
kd> vertarget
Windows 10 Kernel Version 19041 MP (32 procs) Free x64
Product: Server, suite: TerminalServer SingleUserTS
Kernel base = 0xfffff803`5f400000 PsLoadedModuleList = 0xfffff803`6002a2d0
Debug session time: Tue Oct  7 03:14:22.000 2025
System Uptime: 3 days 4:11:07.884

kd> lm m nt
start             end                 module name
fffff803`5f400000 fffff803`5fe12000   nt     (pdb symbols)  ntkrnlmp.pdb

После этого структуры вроде nt!_POOL_HEADER и nt!_POOL_TRACKER_TABLE читаются по именам полей, а не по сырым смещениям. Разница принципиальная: вместо угадывания, что в восьми байтах по смещению 0x08 лежит тег, инженер видит dt nt!_POOL_HEADER адрес с подписанными полями и сразу читает смысл.

При разборе повторяющихся крэшей полезно держать символы под конкретную версию ядра с сервера, где случился сбой. Несовпадение сборок приводит к сдвигу полей структур, и анализ уходит в неверную сторону. Поэтому в документацию к расследованию записывается вывод vertarget, где указана точная версия nt и дата компиляции, а все последующие выводы накрепко привязываются к этой прошивке знаний.

Отдельного упоминания стоят теги пула. Ядро ведёт статистику по тегам, и расшифровка четырёхбуквенных кодов лежит в файле pooltag.txt из комплекта отладочных инструментов. Раскладку по тегам в дампе показывает уже упомянутая команда !poolused. Если среди тегов внезапно всплывает чуждый четырёхбуквенный код, который не принадлежит ни одному известному драйверу системы, это почти всегда след стороннего модуля. Дальше поиск по образам сужает круг:

kd> lm
kd> lm m myfilter
kd> !ndiskd.miniports

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

После того как символы и теги проверены, наступает черёд ложных следов, и отличать их от настоящих учит опыт. Искушение списать вину на драйвер из вершины стека сопровождает каждое расследование, и опытный аналитик ему не поддаётся. Классика жанра - обвинение ndis или tcpip при порче буферов сети: сетевой стек лишь доставляет пакеты, а режет их чужой фильтр, навешанный поверх через обработчики. Проверка проста - по списку фильтров через !ndiskd.miniports и датам из lm виден весь стек сетевых надстроек, после чего вопрос звучит уже адресно.

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

Третья ловушка касается дампов от кластерных и виртуальных систем. Гипервизор инжектирует свои события, и повреждённые страницы порой принадлежат не тому пулу, который видно в дампе. В таких случаях на живой системе дополнительно снимают трассу ETW по событиям Memory Manager и смотрят, не трогал ли кто-то из внешних агентов общие страницы. Это редкий сценарий, но у тех, кто анализирует крэши в облачной среде, он встречается и отнимает время у невнимательных.

Роль Driver Verifier и Special Pool в профилактике следующих дампов

Инструмент, который превращает post-mortem детектив в немедленную поимку, называется Driver Verifier. Его включают из командной строки с повышенными правами, а проверяют текущее состояние и снимают настройки там же:

verifier /standard /driver myfilter.sys
verifier /flags 0x209BB /driver myfilter.sys
verifier /query
verifier /querysettings
verifier /reset

gflags /p /enable myfilter.sys /full
gflags /p /disable myfilter.sys

Флаговая маска 0x209BB включает в том числе special pool и проверку IRQL, а gflags с ключом /full ставит конкретный драйвер в специальный пул точечно, без нагрузки на всю систему. В режиме Special Pool каждое выделение меньше страницы получает свою страницу, выровненную так, что запись за пределы блока сразу бьёт в невыделенную память и рождает bug check 0xD6 DRIVER_PAGE_FAULT_BEYOND_END_OF_ALLOCATION точно в момент порчи. Детектив превращается в камеру наблюдения: виновник стоит в стеке в момент ошибки, и никакой реконструкции не требуется.

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

Ещё один помощник - pool tracking через флаг 0x80 (в терминах verifier). Когда он включён, каждое выделение и освобождение фиксируется с полным стеком, и потом команда !verifier в дампе показывает двойника: стек Выделившего и стек Освободившего. Двойное освобождение при таком раскладе находится за минуту, а история use after free разворачивается как досье с датами и подписями.

Систематический конвейер post-mortem расследования

Опыт выработал устойчивый порядок действий, который сокращает шансы пропустить что-то важное. Сначала собирается полный дамп, а не minidump: для анализа пула требуется карта ядерной памяти, и малый дамп её просто не содержит. В реестре через ветку CrashControl выставляется тип дампа, после чего сервер переводится в режим, где повторная остановка соберёт полный слепок:

reg add HKLM\SYSTEM\CurrentControlSet\Control\CrashControl ^
    /v CrashDumpEnabled /t REG_DWORD /d 1 /f
reg add HKLM\SYSTEM\CurrentControlSet\Control\CrashControl ^
    /v MinidumpDir /t REG_EXPAND_SZ /d "%SystemRoot%\Minidump" /f
reg add HKLM\SYSTEM\CurrentControlSet\Control\CrashControl ^
    /v AlwaysKeepMemoryDump /t REG_DWORD /d 1 /f
wmic recoveros set DebugInfoType = 1

Значение CrashDumpEnabled равное единице соответствует полному дампу, двойка - дампу памяти ядра, и именно двойку чаще всего оставляют на продуктиве как разумный компромисс между полнотой и объёмом файла.

Дальше строится хронология:

kd> !thread
kd> !stacks 2
kd> !locks
kd> !irpfind
kd> !process 0 0

По !thread и !stacks 2 восстанавливается картина потоков на момент остановки, по !locks смотрятся занятые ресурсы ERESOURCE, а по !irpfind находятся подвешенные запросы ввода-вывода. Этот задний план частенько объясняет, какой драйвер был в игре, даже если стек падения его не показывает. Следующий ход - коллекция артефактов: содержимое вокруг повреждённого блока и поиск таких же байт по всему образу памяти:

kd> dc ffffd10a`12345660 L200
kd> s -sa ffffd10a`12345660 L200 "Frag"
kd> s -a ffffc000`00000000 L?0x40000000 "C:\Users\Public\report.dat"
kd> lm n

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

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

Почему умение читать повреждения пула экономит недели работы

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

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