Debugging Windows Kernel Drivers: практичний гайд із Driver Verifier
Усім привіт! В цій статті я, як розробниця kernel mode драйверів для Windows у компанії Apriorit, хотіла б поділитися своїм досвідом тестування та багфіксу драйверного низькорівневого C/C++ коду. Ця стаття дає практичні поради для роботи з Driver Verifier та буде корисна і тим, хто лише починає працювати з драйверами, і тим, хто вже має певний досвід, для кращої систематизації підходів.
Які особливості налагодження драйверів
Найчастіше помилки в kernel mode виникають через проблеми з пам’яттю, синхронізацією, неправильну обробку I/O запитів. Звісно, на цьому причини можливих помилок не закінчуються, але оскільки в цій статті ми поговоримо саме про такі, я утримаюся від перерахування усіх інших імовірних проблем. Діагностика таких помилок ускладнюється ще тим, що драйвери не мають зручного інтерфейсу для налагодження. Усе працює на низькому рівні, і будь-який збій часто завершується синім екраном (BSOD) без достатньої інформації. Симптоми також нерідко виявляються «непрямими», витік пам’яті може проявитися лише через кілька днів, чи, наприклад, помилка в одному драйвері може виглядати як збій у зовсім іншому компоненті системи.
Чому взагалі важливо ретельно тестувати драйвери? Вони працюють у режимі ядра, де навіть незначні помилки можуть поставити під загрозу стабільність і безпеку всієї операційної системи. На відміну від user mode програм, помилка в драйвері може спричинити збої в масштабах всієї системи, включаючи сині екрани та втрату даних. Ретельне тестування драйверів (звісно ж якщо після тестування буде багфікс) захищає як користувачів, так і цілісність системи.
Але не опускаємо руки! Тут нам на допомогу поспішає супергерой Driver Verifier. Це вбудований у Windows інструмент, який створений для виявлення та налагодження помилок у драйверах. Зазвичай Driver Verifier використовують тоді, коли система регулярно падає через драйвер, але з аналізу дампів складно визначити, який компонент чи фрагмент коду винен. Також під час розробки при дев-тестуванні добровільно-примусово рекомендується запускати драйвер під веріфайером, аби вчасно помітити та виправити можливі уразливості.
Driver Verifier, звісно, не єдиний інструмент для налагодження драйверів. Наприклад, ще існує Static Driver Verifier для аналізу драйверного коду до його виконання. Цей інструмент використовує статичний аналіз, щоб перевірити, чи дотримується драйвер правил взаємодії з ядром Windows. Тобто якісь проблеми можна знайти ще на етапі розробки, а не під час тестування чи роботи системи. Але в цій статті ми детально поговоримо саме про Driver Verifier.
Що робить і як працює Driver Verifier
Простими словами, головне завдання веріфайера — це вередувати. Він навмисно навантажує драйвер у нестандартних і стресових умовах, аби спровокувати приховані проблеми, які в нормальній роботі можуть бути доволі не помітними, так би мовити, з уникаючим типом прив’язаності. Driver Verifier запускає перевірки, які допомагають знаходити витоки пам’яті, неправильну роботу з IRP, порушення синхронізації та інші критичні помилки, які часто закінчуються BSOD. Якщо підсумувати, ось основні переваги веріфайера:
- Виявлення помилок у реальному середовищі, перевірка драйверів під час їхньої роботи. Це дозволяє знайти проблеми, які статичний аналіз може пропустити.
- Рання діагностика BSOD, спеціальні стресові умови, щоб швидко виявити нестабільність драйвера.
- Точна інформація для відладки у випадку збоїв, детальні дампи пам’яті та код помилок, які допомагають розробнику локалізувати помилку.
- Можливість обирати як типи перевірок, так і конкретні драйвери, для яких ці перевірки потрібно запустити.
Driver Verifier є дійсно потужним помічником, але його використання пов’язане з низкою ризиків, через що я б порадила його запускати тільки у контрольованому середовищі , наприклад на тестових або віртуальних машинах. Найбільший ризик полягає в тому, що Driver Verifier навмисно створює екстремальні умови, обмежує ресурси, перевантажує пам’ять, моделює нестачу системних об’єктів. А це призводить до синіх екранів смерті. Тож маємо наступні ризики:
- Нестабільність системи, наприклад, часті BSOD і зависання, особливо якщо перевіряти критичні системні драйвери.
- Втрата даних через аварійні завершення роботи системи, що може шкодити відкритим файлам чи БД.
- Зниження продуктивності через додаткові перевірки, які сильно навантажують драйвери та систему.
- Веріфайер іноді може повідомляти про помилки, які на практиці не спричинять критичних проблем.
Налаштування Driver Verifier
За увімкнення та налаштування веріфайера відповідає утиліта verifier.exe, яку потрібно запустити із правами адміністратора. Для перевірки драйвера на найбільш розповсюджені проблеми я б порекомендувала обрати «Create custom settings» і вручну виділити типи перевірок. Серед найбільш корисних і рекомендованих пунктів для пошуку критичних помилок є Special Pool, Force IRQL Checking, Pool Tracking, I/O Verification, а також Deadlock Detection. Можна ще увімкнути Low Resources Simulation, але цей режим може сильно навантажити систему.

Дуже важливо не обирати всі драйвери одразу, особливо системні, інакше Windows може перестати завантажуватися. Краще перевіряти лише ті драйвери, які ви підозрюєте, або ж власні драйвери під час тестування. Ну і після того, як ви завершили налаштування, потрібно перезавантажити систему. Від цього моменту Driver Verifier почне перевіряти та відстежувати роботу обраних драйверів і, у випадку помилки, ми отримаємо BSOD. Після чого в нас буде дамп пам’яті для подальшого аналізу в WinDbg.
Ще корисні команди Driver Verifier:
- verifier /query для перевірки активних налаштувань;
- verifier /reset для вимкнення веріфайера, після виконання слід перезавантажити систему.
Аналіз проблем витоку пам’яті
Витоки пам’яті не викликають миттєвого збою, але з часом системі може бракувати ресурсів на нові виділення пам’яті, застосунки зависають, а драйвер може викликати STATUS_INSUFFICIENT_RESOURCES. Ось типи найбільш розповсюджених витоків пам’яті, з якими я стикалася:
- Незвільнені пули пам’яті, наприклад, використання ExAllocatePoolWithTag без відповідного ExFreePool.
- Загублені об’єкти, хендли на події, семафори, потоки, які не закриваються через ObDereferenceObject або ZwClose.
- Неправильний життєвий цикл IRP / MDL, тобто IRP виділений, але не завершений чи MDL створений через IoAllocateMdl, але не звільнений IoFreeMdl.
NTSTATUS SomeFunc()
{
PVOID buffer = ExAllocatePoolWithTag(NonPagedPool, 1024, 'ymT');
if (!buffer)
{
return STATUS_INSUFFICIENT_RESOURCES;
}
// Some logic...
if (1 > 0) // Some condition failed
{
// ERROR: we return status, but do not free the memory
return STATUS_UNSUCCESSFUL;
}
ExFreePool(buffer); // We may not get here due to some condition failed
return STATUS_SUCCESS;
}Якщо ми будемо запускати цей код під Driver Verifier, під час вивантаження драйвера отримаємо BSOD з такою інформацією в WinDbg:

Тепер можна запустити дві команди:
- !verifier 3 drivet_test.sys для отримання детального розподілу виділеної пам’яті. Тут ми можемо отримати розмір блоку пам’яті, який ми забули звільнити. Як ми бачимо, вказано, що не було звільнено «0×1 for 00000400 bytes». При переводі в base-10 систему це і є 1024 байтів.

- !poolused 2 для отримання інформації про виділену пам’ять по конкретних тегах. Тут можна скористатися пошуком і пробігтися по усіх тегах, які ви використовуєте у своєму драйвері, щоб з’ясувати, під яким тегом відбулося виділення пам’яті, яке спричинило витік пам’яті.
Як задетектити помилку, якщо в коді багато викликів на виділення пам’яті під одним і тим же тегом? Краще винести ExAllocatePoolWithTag в окрему функцію і для тесту додати додаткову умову з перевіркою на розмір. Під час аналізу дампу потрібно виявити розмір блоку пам’яті, який ви забули звільнити. І тоді під час дебагу в WinDbg можна буде поставити брейкпоінт на цю умову та виявити послідовність викликів, які спричинили цей витік пам’яті. Звісно, в цю умову можуть потрапити й коректні сценарії (бо використовується такий же розмір для алокації), проте вибірка для пошуку проблемного коду зменшується.
void* MemoryAlloc(size_t size)
{
if (size == 1024)
{
DbgPrint("Breakpoint");
}
return ExAllocatePoolWithTag(NonPagedPoolNx, size, MEM_TAG);
}Обробка помилок операцій вводу/виводу
Типові помилки при роботі з I/O запитами, з якими ви можете зіштовхнутися:
- Неправильне завершення IRP. Драйвер або не викликає IoCompleteRequest для відправленого IRP, або IRP завершується кілька разів (double completion).
- Повернення некоректного статусу. Наприклад, повернення STATUS_SUCCESS, навіть якщо сталася помилка або повернення STATUS_PENDING без виклику IoMarkIrpPending.
- Втрати або витоки IRP.
- Неправильна робота з Irp->AssociatedIrp.SystemBuffer або Irp->MdlAddress, що спричиняє пошкодження пам’яті.
Зараз я хочу продемонструвати приклад дуже проблемного коду з декількома помилками. Так би мовити, зібрати декілька можливих проблем до купи (в одну функцію).
NTSTATUS DriverDispatchRead(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
UNREFERENCED_PARAMETER(DeviceObject);
PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
ULONG length = stack->Parameters.Read.Length;
PUCHAR buffer = (PUCHAR)ExAllocatePoolWithTag(NonPagedPoolNx, length, 'dAtA');
if (!buffer)
{
// ERROR: IRP returns with SUCCESS status even if it fails
Irp->IoStatus.Status = STATUS_SUCCESS;
Irp->IoStatus.Information = 0;
IoCompleteRequest(Irp, IO_NO_INCREMENT);
return STATUS_SUCCESS;
}
RtlFillMemory(buffer, length, 0xAB);
IoCompleteRequest(Irp, IO_NO_INCREMENT);
// ERROR: we forget to copy the data to the user's buffer
// ERROR: and we incorrectly specify the number of bytes
Irp->IoStatus.Status = STATUS_SUCCESS;
Irp->IoStatus.Information = length + 10;
// ERROR: double IRP complete request
IoCompleteRequest(Irp, IO_NO_INCREMENT);
ExFreePool(buffer);
return STATUS_SUCCESS;
}В цьому випадку Driver Verifier повалив систему з помилкою MULTIPLE_IRP_COMPLETE_REQUESTS. Проте при ретельному тестуванні (наприклад з Low Resource Simulation), ми також могли б потрапити в умову, коли не вдалося виділити пам’ять для буфера. Або код, який викликав Read-операцію, буде звертатися до буфера, використовуючи неправильну довжину.

У випадку такого типа помилки, веріфайер одразу вказує проблемне місце коду, в якому відбувся наступний виклик IoCompleteRequest. Для цього як зазвичай запускаємо «!analyze -v» і дивимося інформацію, яку виводить дебагер:

Діагностика проблем, які допомагає виявити Low Resource Simulation
У режимі Low Resource Simulation функції для алокації, наприклад ExAllocatePoolWithTag, IoAllocateMdl або IoAllocateIrp, можуть повертати NULL набагато частіше, ніж під час звичайної роботи системи, оскільки Driver Verifier штучно створює нестачу пам’яті в системі. Тому будь-яке неперевірене виділення пам’яті є прихованим збоєм, який чекає свого часу, щоб показатися у всій красі.
NTSTATUS SomeFunc()
{
PVOID buffer = ExAllocatePoolWithTag(NonPagedPool, 1024, 'ymT');
// ERROR: no check for memory allocation success
// if (!buffer)
// {
// return STATUS_INSUFFICIENT_RESOURCES;
// }
RtlZeroMemory(buffer, 1024);
ExFreePool(buffer);
return STATUS_SUCCESS;
}В цьому випадку ми одразу отримаємо BSOD, коли будемо намагатися звернутися до виділеного буфера. Стек викликів буде вказувати на RtlZeroMemory, де ми намагаємося використати буфер, значення якого NULL через нестачу ресурсів. Особисто я під час запуску цього фрагменту коду отримала помилку SYSTEM_THREAD_EXCEPTION_NOT_HANDLED. Але тип помилки залежить від того, що ми збираємося робити з нульовим об’єктом, в яку функцію передавати й так далі. Тому в цьому випадку найбільш змістовним є стек, по якому ми можемо виявити проблемний фрагмент коду.

Діагностика проблем, які допомагають виявити Special Heap-перевірки
Як працює режим Special Heap-перевірок? Driver Verifier переміщує звільнену пам’ять у захищену карантинну область. Після цього будь-яке читання/запис негайно призведе до перевірки на помилки, що запобігатиме непомітному пошкодженню пам’яті.
NTSTATUS SomeFunc()
{
PVOID buffer = ExAllocatePoolWithTag(NonPagedPool, 1024, 'ymT');
if (!buffer)
{
return STATUS_INSUFFICIENT_RESOURCES;
}
// ERROR: length is greater than buffer size
RtlZeroMemory(buffer, 1024 + 100);
ExFreePool(buffer);
return STATUS_SUCCESS;
}Як і в минулому прикладі, ми отримаємо BSOD на функції RtlZeroMemory, оскільки спробували записати в буфер більше, ніж було для нього виділено. Тільки на цей раз маємо тип помилки PAGE_FAULT_IN_NONPAGED_AREA.

Поради для запобігання помилкам у майбутньому і висновки
Час підсумувати усе вищесказане і стисло розповісти, що робити, щоб бути крутим продуманим драйверним розробником, який одразу пише код з мінімальною кількістю помилок! Ось мої takeaways:
- Керування пам’ятю:
- Кожне виділення через ExAllocatePoolWithTag або інші API має мати унікальний тег (4 символи). Це дозволить під час тестування з веріфайером легко знайти витоки через !poolused
- Старайтеся організувати код так, щоб виділення й звільнення ресурсу завжди було поруч і логічно пов’язане. Наприклад, можна використовувати __try/__finally блоки:
- Завжди перевіряйте результат будь-якого виділення пам’яті на NULL.
NTSTATUS SomeFunc()
{
PVOID buffer = ExAllocatePoolWithTag(NonPagedPool, 1024, 'pmLT');
if (!buffer) return STATUS_INSUFFICIENT_RESOURCES;
__try
{
// Use buffer
}
__finally
{
if (buffer) ExFreePool(buffer);
}
}1. IRP та I/O:
- Не ігноруйте коди повернення з «нижніх рівнів», правильно передавайте і повертайте NTSTATUS.
- Перевіряйте Irp->IoStatus.Status перед завершенням.
- Уникайте подвійного виклику IoCompleteRequest, а також «висячих» IRP, усі вони мають завершуватися.
2. Low Resource Simulation:
- Пишіть код так, ніби в системі майже нема вільної пам’яті.
- Відразу повертайте STATUS_INSUFFICIENT_RESOURCES, якщо виділення не вдалося.
3. Special Pool / Special Heap-перевірки:
- Завжди ініціалізуйте пам’ять перед використанням, наприклад, за допомогою RtlZeroMemory або ExAllocatePoolZero.
- Не виходьте за межі виділених буферів, робіть перевірки на розмір.
- Не звертайся до пам’яті після її звільнення.
Отже, колеги-розробники! Систематичне керування пам’яттю, правильна обробка I/O, перевірка ресурсів і регулярне тестування під Driver Verifier є ключем до стабільних, надійних драйверів і нашого щасливого безбажного життя.

13 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів