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

Этот API также можно использовать при отладке в реальном времени, будь то отладка процесса в пользовательском режиме или отладка ядра. В этом посте будет показано, как использовать его для анализа дампа памяти, но его можно относительно легко преобразовать в отладку в реальном времени.

Основное преимущество API отладчика заключается в том, что он использует определенные символы для версии Windows, с которой он работает, что позволяет нам писать код, который будет работать с любой версией Windows, без необходимости хранить постоянно растущий заголовок структур для разных версий, и необходимость выбирать правильный и обновлять наш код каждый раз при изменении структуры. Например, обычная структура данных, которую следует рассматривать в Windows, - это процесс, представленный в ядре структурой EPROCESS. Эта структура меняет почти каждую сборку Windows, а это означает, что поля внутри нее продолжают перемещаться. Интересующее нас поле может иметь смещение 0x100 в одной версии Windows, 0x120 в другой, 0x108 в другой и так далее. Если мы используем неправильное смещение, драйвер не будет работать должным образом и может случайно вывести систему из строя. Используя символы, мы также получаем правильный размер и тип каждой структуры и ее подструктур, поэтому вложенная структура становится больше или поле меняет свой тип, например, это блокировка push в одной версии и блокировка вращения в другой. , будет правильно обрабатываться API отладчика без изменений кода на нашей стороне.

API отладчика полностью избегает этой проблемы за счет использования символов, поэтому мы можем написать наш код один раз, и он будет успешно работать на дампах, взятых из всех возможных версий Windows, без каких-либо обновлений при выпуске новых сборок. Кроме того, он работает в пользовательском режиме, поэтому у него нет всех неотъемлемых рисков, которые несет код режима ядра, и, поскольку он может работать с файлом дампа, его не нужно запускать на машине, которую он анализирует. Это может быть огромным преимуществом, поскольку иногда мы не можем запустить наши инструменты отладки на интересующей нас машине. Это также позволяет нам делать чрезвычайно сложные вещи на гораздо более быстрых машинах, например анализировать дамп - или множество дампов - в облако.

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

В этом посте мы узнаем, как написать простую программу, которая открывает дамп памяти, выполняет итерацию по всем процессам и печатает имя и PID каждого из них. Для тех, кто не знаком с представлением процессов в ядре Windows, все процессы связаны вместе связанным списком (это структура LIST_ENTRY, указывающая на следующую запись и предыдущую запись). На этот список указывает символ nt!PsActiveProcessHead, и список находится в поле ActiveProcessLinks структуры EPROCESS. Конечно, символ не экспортируется, и структура EPROCESS недоступна ни в одном из общедоступных заголовков, поэтому реализация этого в драйвере потребует некоторых жестко запрограммированных смещений и проверок версий, чтобы получить правильные смещения для каждого из них. Или мы можем использовать вместо этого API отладчика!

Чтобы получить доступ ко всем этим функциям, нам нужно включить DbgEng.h и указать ссылку на DbgEng.lib. И это подходящее время для важного совета, которым поделился Алекс Ионеску: связанные с отладкой библиотеки DLL, поставляемые Windows, нестабильны и часто просто не работают вообще, оставляя вас в замешательстве и недоумевающих, что вы сделали не так и почему ваш код был идеальным. хороший вчерашний день внезапно рушится. WinDbg поставляется со своими собственными версиями всех DLL, необходимых для этой функциональности, которые намного лучше. Итак, вам нужно скопировать Dbgeng.dll, Dbghelp.dll и Symsrv.dll из каталога, в котором находится windbg.exe, в выходной каталог этого проекта. Делайте все, что вам нужно, и не забывайте всегда использовать библиотеки DLL, которые поставляются с WinDbg, это сэкономит вам много времени и избавит вас от разочарований в будущем.

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

IDebugClient* debugClient;
IDebugSymbols* debugSymbols;
IDebugDataSpaces* dataSpaces;
IDebugControl* debugControl;

Это позволит нам открыть дамп, получить доступ к его памяти и символам для всех модулей в нем и использовать их для анализа содержимого дампа. Сначала мы вызываем DebugCreate для инициализации переменной debugClient:

DebugCreate(__uuidof(IDebugClient), (PVOID*)&debugClient);

Обратите внимание, что все функции, которые мы здесь будем использовать, возвращают HRESULT, который следует проверить с помощью SUCCEEDED(result). В этом посте я пропущу эти проверки, чтобы код был меньше и легче читался, но в любой реальной программе пропускать их нельзя.

После того, как мы инициализировали debugClient, мы можем использовать его для инициализации остальных 3:

debugClient->QueryInterface(__uuidof(IDebugSymbols), 
                            (PVOID*)&debugSymbols);
debugClient->QueryInterface(__uuidof(IDebugDataSpaces), 
                            (PVOID*)&dataSpaces);
debugClient->QueryInterface(__uuidof(IDebugControl), 
                            (PVOID*)&debugControl);

Итак, настройка завершена. Мы можем открыть наш файл дампа с помощью debugClient->OpenDumpFile, а затем дождаться загрузки всех файлов символов:

debugClient->OpenDumpFile(DumpFilePath);
debugControl->WaitForEvent(DEBUG_WAIT_DEFAULT, 0);

Как только дамп загружен, мы можем приступить к его чтению. Модуль, который нас больше всего интересует, - это nt - мы собираемся использовать символ PsActiveProcessHead, а также структуру EPROCESS, которая ему принадлежит. Итак, нам нужно получить базу модуля, используя dataSpaces->ReadDebuggerData. Эта функция получает 4 аргумента - Index, Buffer, BufferSize и DataSize. Последний - необязательный выходной параметр, сообщающий нам, сколько байтов было записано или, если размер буфера недостаточно велик, сколько байтов необходимо. Для простоты мы всегда будем передавать nullptr как DataSize, поскольку мы заранее знаем необходимые размеры для всех наших данных. Второй и третий аргументы довольно ясны, поэтому о них много говорить не приходится. И для первого аргумента нам нужно посмотреть список опций, найденный на DbgEng.h:

// Indices for ReadDebuggerData interface
#define DEBUG_DATA_KernBase 24
#define DEBUG_DATA_BreakpointWithStatusAddr 32
#define DEBUG_DATA_SavedContextAddr 40
#define DEBUG_DATA_KiCallUserModeAddr 56
#define DEBUG_DATA_KeUserCallbackDispatcherAddr 64
#define DEBUG_DATA_PsLoadedModuleListAddr 72
#define DEBUG_DATA_PsActiveProcessHeadAddr 80
#define DEBUG_DATA_PspCidTableAddr 88
#define DEBUG_DATA_ExpSystemResourcesListAddr 96
#define DEBUG_DATA_ExpPagedPoolDescriptorAddr 104
#define DEBUG_DATA_ExpNumberOfPagedPoolsAddr 112
...

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

Первым индексом в этом списке будет DEBUG_DATA_KernBase. Итак, мы создаем переменную для получения базового адреса модуля nt и вызываем ReadDebuggerData:

ULONG64 kernBase;
dataSpaces->ReadDebuggerData(DEBUG_DATA_KernBase,
                             &kernBase,
                             sizeof(kernBase),
                             nullptr);

Затем мы хотим перебрать все процессы и распечатать информацию о них. Для этого нам понадобится тип EPROCESS. Одна неприятная вещь в API отладчика заключается в том, что он не позволяет нам использовать типы, как если бы они были в файле заголовка. Мы не можем объявить переменную типа EPROCESS и получить доступ к ее полям. Вместо этого нам нужно получить доступ к памяти через идентификатор типа и смещения внутри типа. Например, если мы хотим получить доступ к полю ImageFileName внутри процесса, нам нужно будет прочитать информацию, которая находится в processAddr + imageFileNameOffset. Но это забегает вперед. Сначала нам нужно получить идентификатор типа _EPROCESS, используя debugSymbols->GetTypeId, который получает базу модуля, имя типа и выходной аргумент для идентификатора типа. Как следует из названия, эта функция не предоставляет нам сам тип, а только идентификатор, который мы будем использовать для получения смещений внутри структуры:

ULONG EPROCESS;
debugSymbols->GetTypeId(kernBase, “_EPROCESS”, &EPROCESS);

Теперь давайте получим смещения полей внутри EPROCESS, чтобы мы могли легко получить к ним доступ. Поскольку мы хотим напечатать имя и PID каждого процесса, нам потребуются поля ImageFileName и UniqueProcessId в дополнение к ActiveProcessLinks, поэтому мы перебираем процессы. Чтобы получить их, мы вызовем debugSymbols->GetFieldOffset, который получает базу модуля, идентификатор типа, имя поля и выходной аргумент, который получит смещение поля:

ULONG imageFileNameOffset;
ULONG uniquePidOffset;
ULONG activeProcessLinksOffset;
debugSymbols->GetFieldOffset(kernBase,
                             EPROCESS,
                             “ImageFileName”,
                             &imageFileNameOffset);
debugSymbols->GetFieldOffset(kernBase,
                             EPROCESS,
                             “UniqueProcessId”,
                             &uniquePidOffset);
debugSymbols->GetFieldOffset(kernBase,
                             EPROCESS,
                             “ActiveProcessLinks”,
                             &activeProcessLinksOffset);

Чтобы начать итерацию списка процессов, нам нужно прочитать PsActiveProcessHead. Вы могли заметить ранее, что этот символ имеет индекс в DbgEng.h, поэтому его можно прочитать напрямую с помощью ReadDebuggerData. Но в этом примере мы не будем так читать, а вместо этого покажем, как читать как символ, не имеющий индекса. Итак, сначала нам нужно получить смещение символа в файле дампа, используя debugSymbols->GetOffsetByName:

ULONG64 activeProcessHead;
debugSymbols->GetOffsetByName(“nt!PsActiveProcessHead”,  
                              &activeProcessHead);

Это пока не дает нам фактического значения, только смещение этого символа. Чтобы получить значение, нам нужно будет прочитать память, на которую указывает этот адрес, из дампа, используя dataSpaces->ReadVirtual, который получает адрес для чтения, Buffer, BufferSize и необязательный выходной аргумент BytesRead. Мы знаем, что этот символ указывает на структуру LIST_ENTRY, поэтому мы можем просто определить локальный связанный список и прочитать в него переменную. В данном случае нам повезло - структура LIST_ENTRY задокументирована. Если бы этот символ содержал недокументированную структуру, этот процесс потребовал бы еще нескольких шагов и был бы немного более болезненным.

LIST_ENTRY activeProcessLinks;
dataSpaces->ReadVirtual(activeProcessHead,
                        &activeProcessLinks,
                        sizeof(activeProcessLinks),
                        nullptr);

Теперь у нас есть почти все, что нужно, чтобы начать итерацию списка процессов! Мы определим локальную переменную процесса и будем использовать ее для хранения адреса текущего процесса, который мы ищем. На каждой итерации activeProcessLinks.Flink будет указывать на первый процесс в системе, но не на начало EPROCESS. Он указывает на поле ActiveProcessLinks, поэтому, чтобы добраться до начала структуры, нам нужно вычесть смещение поля ActiveProcessLinks из адреса (в основном то, что сделал бы макрос CONTAINING_RECORD, если бы мы могли использовать его здесь). Обратите внимание, что мы используем ULONG64 здесь специально вместо ULONG_PTR, чтобы избавить нас от боли, связанной с использованием арифметики указателей и избеганием приведений в будущих вызовах функций, поскольку большинство функций API отладчика получают аргументы как ULONG64:

ULONG64 process;
process = (ULONG64)activeProcessLinks.Flink — activeProcessLinksOffset;

Итерация процесса довольно проста - для каждого процесса мы хотим прочитать значение ImageFileName и значение UniqueProcessId, а затем прочитать указатель следующего процесса из ActiveProcessLinks. Обратите внимание, что мы не можем напрямую получить доступ к каким-либо данным в отладчике. Адреса, которые у нас есть, не имеют смысла в контексте нашего текущего процесса (они также являются адресами ядра, и наше приложение работает в пользовательском режиме, а не обязательно на правильном компьютере), и нам нужно вызвать dataSpaces->ReadVirtual или любой другой отладчик функции, которые позволяют нам читать данные для доступа к любой из памяти и должны будут читать эти значения для каждого процесса.

Как правило, нам не нужно читать каждое значение отдельно, мы также можем прочитать всю структуру EPROCESS с debugSymbols->ReadTypedDataVirtual для каждого процесса, а затем получить доступ к полям по их смещениям. Но структура EPROCESS очень велика, и нам нужно только несколько конкретных полей, поэтому чтение всей структуры довольно расточительно и в этом случае не требуется.

Теперь у нас есть все необходимое для реализации итерации нашего процесса:

UCHAR imageFileName[15];
ULONG64 uniquePid;
LIST_ENTRY activeProcessLinks;
do
{
    //
    // Read process name, pid and activeProcessLinks 
    // for the current process
    //
    dataSpaces->ReadVirtual(process + imageFileNameOffset,
                            &imageFileName,
                            sizeof(imageFileName),
                            nullptr);
    dataSpaces->ReadVirtual(process + uniquePidOffset,
                            &uniquePid,
                            sizeof(uniquePid),
                            nullptr);
    dataSpaces->ReadVirtual(process + activeProcessLinksOffset,
                            &activeProcessLinks,
                            sizeof(activeProcessLinks),
                            nullptr);
    printf(“Current process name: %s, pid: %d\n”,
           imageFileName,
           uniquePid);
    //
    // Get the next process from the list and
    // subtract activeProcessLinksOffset
    // to get to the start of the EPROCESS.
    //
    process = (ULONG64)activeProcessLinks.Flink — activeProcessLinksOffset;
} while ((ULONG64)activeProcessLinks.Flink != activeProcessHead);

Вот и все, что нам нужно, чтобы получить такой хороший результат:

Некоторые из вас могут заметить, что некоторые из этих имен процессов выглядят неполными. Это связано с тем, что поле ImageFileName содержит только первые 15 байтов имени процесса, в то время как полное имя сохраняется в структуре OBJECT_NAME_INFORMATION (которая на самом деле просто UNICODE_STRING) в SeAuditProcessCreationInfo.ImageFileName. Но в этом посте я хотел упростить задачу, поэтому мы будем использовать здесь ImageFileName.

Осталось только одно - быть хорошими разработчиками и убирать за собой:

if (debugClient != nullptr)
{
    debugClient->EndSession(DEBUG_END_ACTIVE_DETACH);
    debugClient->Release();
}
if (debugSymbols != nullptr)
{
    debugSymbols->Release();
}
if (dataSpaces != nullptr)
{
    dataSpaces->Release();
}
if (debugControl != nullptr)
{
    debugControl->Release();
}

Это было очень краткое, но, надеюсь, полезное введение в API отладчика. С этим доступно бесконечное количество опций, посмотрите DbgEng.h или официальную документацию, чтобы раскрыть намного больше. Я надеюсь, что вы все найдете это так же полезно, как и я, и найдете новые и интересные вещи, для которых можно его использовать.