Пишем свой отладчик под Windows [часть 1]

в 12:24, , рубрики: c++, windows, отладка, Отладчик, Программирование, системное программирование

Вступление

Пишем свой отладчик под Windows [часть 1]
Все мы, время от времени, используем дебаггер для отладки программ. Отладчик может использоваться с C++, C#, Java и ещё сотней других языков. Он может быть как внешним (WinDbg), так и встроенным в среду разработки (Visual Studio). Но вы хоть раз задавались вопросом, как же работает отладчик?
И вам повезло. В этом цикле статей мы разберёмся от и до, как же работает отладка изнутри. В этой статье рассматривается только написание отладчика под Windows. Без компиляторов, линковщиков и других сложных систем. Таким образом, мы сможем отлаживать только исполняемые файлы, так как мы напишем внешний отладчик. Эта статья потребует от читателя понимание основ многопоточности.

Как отлаживать программу:

  1. Запустить процесс с флагом DEBUG_ONLY_THIS_PROCESS или DEBUG_PROCESS;
  2. запустить цикл дебага, который будет отлавливать сообщения и события;

Прежде, чем мы начнём, запомните:

  • Дебаггер — это процесс/программа, которая будет отлаживать другой процесс;
  • отлаживаемая программа (ОП) – это процесс/программа, которая отлаживается;
  • именно отладчик присоединяется к ОП. Также отлачик может подключаться к различным процессам (в разных потоках);
  • отлаживать можно лишь те процессы, которые были запущены из под отладчика. Таким образом, CreateProcess и цикл отладчика должны находится в одном потоке;
  • когда завершается процесс отладчика, то он также завершает ОП;
  • Когда отладчик занят обработкой событий, он замораживает все потоки ОП на время. Об этом позже;

Запуск процесса с флагом отладки

Запускаем процесс с помощью функции CreateProcess и в шестом её параметра (dwCreationFlags) указываем флаг DEBUG_ONLY_THIS_PROCESS. Этот флаг указывает Windows подготовить запускаемый процесс для отладки (отладочные события, старт/завершение процесса, исключения и т.п.). Более подробное объяснение чуть позже. Прошу обратить внимание, что мы будем использовать именно DEBUG_ONLY_THIS_PROCESS. Это значит, что мы хотим отлаживать только тот процесс, который мы запускаем, а не ещё и порождаемые им.

STARTUPINFO si; 
PROCESS_INFORMATION pi; 
ZeroMemory( &si, sizeof(si) ); 
si.cb = sizeof(si); 
ZeroMemory( &pi, sizeof(pi) );

CreateProcess ( ProcessNameToDebug, NULL, NULL, NULL, FALSE, 
                DEBUG_ONLY_THIS_PROCESS, NULL,NULL, &si, &pi );

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

Отладочный цикл

Отладочный цикл – это сердце отладчика, и строится он вокруг функции WaitForDebugEvent. Она получает два параметра: указатель на структуру DEBUG_EVENT и таймаут (DWORD). В качестве таймаута мы укажем INFINITE. Эта функция содержится в kernel32.dll, поэтому никаких дополнительных библиотек нам линковать не надо.

BOOL WaitForDebugEvent(DEBUG_EVENT* lpDebugEvent, DWORD dwMilliseconds);

Структура DEBUG_EVENT включает в себя много отладочной информации: код события, ID процесса, ID потока и прикладную информацию о событии. Как только WaitForDebugEvent завершится и вернёт нам управление, мы получим сообщение отладчика, а после этого вызовем ContinueDebugEvent для продолжения выполнения кода. Ниже вы можете увидеть минимальный отладочный цикл.

DEBUG_EVENT debug_event = {0};
for(;;)
{
    if (!WaitForDebugEvent(&debug_event, INFINITE))
        return;
    ProcessDebugEvent(&debug_event);  // User-defined function, not API
    ContinueDebugEvent(debug_event.dwProcessId,
                      debug_event.dwThreadId,
                      DBG_CONTINUE);
}

Вызывая ContinueDebugEvent, мы просим ОС продолжить выполнение ОП. dwProcessId и dwThreadId указывает нам на процесс и поток. Эти значения мы получили из WaitForDebugEvent. Последний параметр указывает, продолжить выполнение или нет. Этот параметр будет иметь значение только тогда, когда в отладку пришло исключение. Это мы рассмотрим позже. Ну а пока используем просто DBG_CONTINUE (другое возможное значение – это DBG_EXCEPTION_NOT_HANDLED).

Получение событий отладки

Есть девять основных событий отладки, и 20 подсобытий в категории исключений. Рассмотрим это, начиная с самого простого. Ниже приведена структура DEBUG_EVENT:

struct DEBUG_EVENT
{
    DWORD dwDebugEventCode;
    DWORD dwProcessId;
    DWORD dwThreadId;
    union {
        EXCEPTION_DEBUG_INFO Exception;
        CREATE_THREAD_DEBUG_INFO CreateThread;
        CREATE_PROCESS_DEBUG_INFO CreateProcessInfo;
        EXIT_THREAD_DEBUG_INFO ExitThread;
        EXIT_PROCESS_DEBUG_INFO ExitProcess;
        LOAD_DLL_DEBUG_INFO LoadDll;
        UNLOAD_DLL_DEBUG_INFO UnloadDll;
        OUTPUT_DEBUG_STRING_INFO DebugString;
        RIP_INFO RipInfo;
    } u;
};

Когда WaitForDebugEvent успешно завершается, он заполняет эту структуру. dwDebugEventCode указывает, какое событие отладки к нам пришло. В зависимости от этого кода, один из членов union’a u содержит информацию о событии. Например, если dwDebugEventCode==OUTPUT_DEBUG_STRING_EVENT, то верно заполнится тольк OUTPUT_DEBUG_STRING_INFO.

Обработка OUTPUT_DEBUG_STRING_EVENT

Для вывода текста в output, разработчики обычно пользуются функцией OutputDebugString. В зависимости от языка/фреймворка, который вы используете, вы должны быть знакомы с макросами TRACE/ATLTRACE. Разработчики .NET, возможно, знакомы с System.Diagnostics.Debug.Print/System.Diagnostics.Trace.WriteLine. Но все эти методы вызывают OutputDebugString, если объявлен макрос _DEBUG, и отладчик получает сообщение.
Когда сообщение отладки получено, мы обрабатываем DebugString. Структура OUTPUT_DEBUG_STRING_INFO представлена ниже:

struct OUTPUT_DEBUG_STRING_INFO
{
   LPSTR lpDebugStringData;  // char*
   WORD fUnicode;
   WORD nDebugStringLength;
};

Поле nDebugStringLength содержит в себе длину строки, включая завершающий null. Поле fUnicode равно нулю, если строка ANSI, и не равна нулю, если юникод. В этом случае, мы должны считывать nDebugStringLength x2 байт. Внимание! lpDebugStringData содержит указатель на строку с сообщением, но указатель ссылается на данные относительно памяти отлаживаемой программы, а не отладчика.
Чтобы прочитать данные из памяти другого процесса, нам необходимо вызвать ReadProcessMemory и у нас должно на это быть разрешение. Так как мы же и создали процесс для отладки, то проблем с разрешением нет.

case OUTPUT_DEBUG_STRING_EVENT:
{
   CStringW strEventMessage;  // Force Unicode
   OUTPUT_DEBUG_STRING_INFO & DebugString = debug_event.u.DebugString;

   WCHAR *msg=new WCHAR[DebugString.nDebugStringLength];
   // Don't care if string is ANSI, and we allocate double...

   ReadProcessMemory(pi.hProcess,       // HANDLE to Debuggee
         DebugString.lpDebugStringData, // Target process' valid pointer
         msg,                           // Copy to this address space
         DebugString.nDebugStringLength, NULL);

   if ( DebugString.fUnicode )
      strEventMessage = msg;
   else
      strEventMessage = (char*)msg; // char* to CStringW (Unicode) conversion.

   delete []msg;
   // Utilize strEventMessage
}

Что если ОП завершится во время считывания памяти?

Что ж, такого не будет  Позвольте вам напомнить, что отладчик замораживает все потоки ОП во время отработки отладочного сообщения. Таким образом, сам себя процесс завершить не сможет, Ни один диспетчер задач (стандартный или нет) так же не сможет завершить процесс. Если попробовать, то в следующем сообщении наш отладчик получит событие EXIT_PROCESS_DEBUG_EVENT.

Обработка CREATE_PROCESS_DEBUG_EVENT

Событие появляется, когда ОП только запускается. Это должно быть первое сообщение, которое получает отладчик. Для этого сообщения, соответствующее поле DEBUG_EVENT будет CreateProcessInfo. Ниже вы можете увидеть саму структуру CREATE_PROCESS_DEBUG_INFO:

struct CREATE_PROCESS_DEBUG_INFO
{
    HANDLE hFile;   // The handle to the physical file (.EXE)
    HANDLE hProcess; // Handle to the process
    HANDLE hThread;  // Handle to the main/initial thread of process
    LPVOID lpBaseOfImage; // base address of the executable image
    DWORD dwDebugInfoFileOffset;
    DWORD nDebugInfoSize;
    LPVOID lpThreadLocalBase;
    LPTHREAD_START_ROUTINE lpStartAddress;
    LPVOID lpImageName;  // Pointer to first byte of image name (in Debuggee)
    WORD fUnicode; // If image name is Unicode.
};

Обратите внимание, что hProcess и hThread могут отличаться от тех, которые мы получаем в PROCESS_INFORMATION. ID процесса и потока должны быть теми же. Каждый хэндл, который вы получаете от Windows, отличается от остальных. Для этого есть различные причины.
hFile, так же как и lpImageName, может использоваться для получения имени файла ОП. Правда мы уже знаем имя этого файла, ведь мы его и запустили. Но расположение EXE или DLL нам важно знать, потому что при получении сообщения LOAD_DLL_DEBUG_EVENT, хорошо бы знать имя библиотеки.
Как вы можете прочитать в MSDN, lpImageName никогда не содержит полное имя файла и оно будет содержаться в памяти ОП. Более того, не существует гарантий, что в памяти ОП будет также лежать полное имя файла. А ещё имя файла может быть неполный. Поэтому, мы будет получать имя файла из hFile.

Как получить имя файла из hFile

К сожалению, нам необходимо будет использовать метод, описанный в MSDN, который содержит примерно 10 вызовов функций. Ниже сокращённый вариант:

case CREATE_PROCESS_DEBUG_EVENT:
{
   CString strEventMessage = 
     GetFileNameFromHandle(debug_event.u.CreateProcessInfo.hFile);
   // Use strEventMessage, and other members
   // of CreateProcessInfo to intimate the user of this event.
}

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

Обработка LOAD_DLL_DEBUG_EVENT

Это событие похоже на CREATE_PROCESS_DEBUG_EVENT, и как вы уже догадались, это событие вызывается, когда ОС загружает DLL. Это событие возникает каждый раз, когда загружается DLL, явно или неявно. Отладочная информация содержит только время, когда была загруженаDLL, и её виртуальный адрес. Для обработки события, мы используем поле union’a LoadDll. Оно имеет тип LOAD_DLL_DEBUG_INFO

struct LOAD_DLL_DEBUG_INFO
{
   HANDLE hFile;         // Handle to the DLL physical file.
   LPVOID lpBaseOfDll;   // The DLL Actual load address in process.
   DWORD dwDebugInfoFileOffset;
   DWORD nDebugInfoSize;
   LPVOID lpImageName;   // These two member are same as CREATE_PROCESS_DEBUG_INFO
   WORD fUnicode;
};

Для получения имени файла, мы будем использовать функцию GetFileNameFromHandle, такую же, как мы использовали в CREATE_PROCESS_DEBUG_EVENT. Я покажу этот код, когда буду рассказывать про UNLOAD_DLL_DEBUG_EVENT. Событие UNLOAD_DLL_DEBUG_EVENT не содержит полной информации об имени DLL библиотеки.

Обработка CREATE_THREAD_DEBUG_EVENT

Это событие генерируется, когда ОП создаёт новый поток. Практически как CREATE_PROCESS_DEBUG_EVENT, это событие создаётся перед тем, как новый поток будет запущен. Чтобы получить информацию об этом событии, мы используем поле CreateThread. Структура CREATE_THREAD_DEBUG_INFO описана ниже:

struct CREATE_THREAD_DEBUG_INFO
{
  // Handle to the newly created thread in debuggee
  HANDLE hThread;
  LPVOID lpThreadLocalBase;
  // pointer to the starting address of the thread
  LPTHREAD_START_ROUTINE lpStartAddress;
};

ID потока доступен в DEBUG_EVENT::dwThreadId, поэтому нам легко вывести всю информацию о потоке:

case CREATE_THREAD_DEBUG_EVENT:
{
   CString strEventMessage;
   strEventMessage.Format(L"Thread 0x%x (Id: %d) created at: 0x%x",
            debug_event.u.CreateThread.hThread,
            debug_event.dwThreadId,
            debug_event.u.CreateThread.lpStartAddress);
            // Thread 0xc (Id: 7920) created at: 0x77b15e58
}

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

Обработка EXIT_THREAD_DEBUG_EVENT

Это событие генерируется, как только дочерний поток завершается и возвращает код возврата в систему. Поле dwThreadId в DEBUG_EVENT содержит ID завершающегося потока. Для получения хэндла потока и другой информации из CREATE_THREAD_DEBUG_EVENT, нам необходимо хранить эту информацию в каком-либо массиве. Для получения информации об этом событии, мы используем поле ExitThread, которое имеет тип EXIT_THREAD_DEBUG_INFO:

struct EXIT_THREAD_DEBUG_INFO
{
   DWORD dwExitCode; // The thread exit code of DEBUG_EVENT::dwThreadId
};

Ниже код обработчика события:

case EXIT_THREAD_DEBUG_EVENT:
{
   CString strEventMessage;
   strEventMessage.Format( _T("The thread %d exited with code: %d"),
      debug_event.dwThreadId,
      debug_event.u.ExitThread.dwExitCode);    // The thread 2760 exited with code: 0
}

Обработка UNLOAD_DLL_DEBUG_EVENT

Конечно же событие содержит информацию и выгружаемой DLL из памяти ОП. Но не всё так просто! Оно генерируется только в случае вызова FreeLibrary, а не когда система сама выгружает библиотеку. Для получения информации, используйте UnloadDll (UNLOAD_DLL_DEBUG_INFO):

struct UNLOAD_DLL_DEBUG_INFO
{
    LPVOID lpBaseOfDll;
};

Как вы видите, для нас доступен только базовый адрес библиотеки. Именно поэтому я не рассказал вам сразу про код для LOAD_DLL_DEBUG_EVENT. Во время загрузки DLL, мы также получаем lpBaseOfDll. Можно использовать Map для хранения имени библиотеки, помимо её адреса.
Важно заметить, что не все события загрузки библиотеки получат своё событие выгрузки. Тем не менее, мы должны хранить все имена библиотек, так как LOAD_DLL_DEBUG_EVENT не даёт нам информации о том, как библиотека была загружена.
Ниже код для обработки обоих событий:

std::map < LPVOID, CString > DllNameMap;
...
case LOAD_DLL_DEBUG_EVENT:
{
   strEventMessage = GetFileNameFromHandle(debug_event.u.LoadDll.hFile);


   // Storing the DLL name into map. Map's key is the Base-address
   DllNameMap.insert(
      std::make_pair( debug_event.u.LoadDll.lpBaseOfDll, strEventMessage) );

   strEventMessage.AppendFormat(L" - Loaded at %x", debug_event.u.LoadDll.lpBaseOfDll);
}
break;
...
case UNLOAD_DLL_DEBUG_EVENT:
{
   strEventMessage.Format(L"DLL '%s' unloaded.",
      DllNameMap[debug_event.u.UnloadDll.lpBaseOfDll] ); // Get DLL name from map.
}
break;

Обработка EXIT_PROCESS_DEBUG_EVENT

Это одно из самых простых событий, и как вы можете догадаться, вызывается тогда, когда процесс ОП завершается. Это событие показывает нам, как завершился процесс: нормально или экстренно (например, через диспетчер задач), или отлаживаемая программа упала. Информацию мы получаем из EXIT_PROCESS_DEBUG_INFO ExitProcess;

struct EXIT_PROCESS_DEBUG_INFO
{
    DWORD dwExitCode;
};

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

bool bContinueDebugging=true;
...
case EXIT_PROCESS_DEBUG_EVENT:
{
   strEventMessage.Format(L"Process exited with code:  0x%x", 
                          debug_event.u.ExitProcess.dwExitCode);
   bContinueDebugging=false;
}
break;

Обработка EXCEPTION_DEBUG_EVENT

Это самая удивительная и сложная вещь во всех событиях отладки. Из MSDN:

Это событие генерируется, когда возникает исключение в отлаживаемом процессе (возможно при делении на ноль, выходе за границы массива, выполнения инструкции int 3 или любого другого исключения, описанного в SEH). Структура DEBUG_EVENT содержит структуру EXCEPTION_DEBUF_INFO. Именно она описывает исключение.

Описание обработки этого события требует отдельной статьи, чтобы рассказать про это полностью (да пусть хоть даже и частично). Поэтому я расскажу пока про один тип исключения.
Поле Exception содержит информацию о только что произошедшем исключении. Ниже можно увидеть описание структуры EXCEPTION_DEBUG_INFO:

struct EXCEPTION_DEBUG_INFO
{
    EXCEPTION_RECORD ExceptionRecord;
    DWORD dwFirstChance;
};

Поле ExceptionRecord содержит детальную информацию об исключении.

struct EXCEPTION_RECORD
{
    DWORD     ExceptionCode;
    DWORD     ExceptionFlags;
    struct _EXCEPTION_RECORD *ExceptionRecord;
    PVOID     ExceptionAddress;
    DWORD     NumberParameters;
    ULONG_PTR ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS];  // 15
};

Прежде чем мы углубимся в EXCEPTION_RECORD, хотелось бы с вами обсудить EXCEPTION_DEBUG_INFO::dwFirstChance
Когда процесс находится под отладкой, отладчик всегда получает исключение до того, как ОП его получит. Вы должно быть видели запись “First-chance exception at 0x00412882 in SomeModule” пока отлаживали приложение под С++. Это ссылается на First Chance исключения. Такие же исключения могут быть, а могут не быть на second chance исключениях.
Когда ОП кидает исключение, оно трактуется как second chance. ОП может обработать это исключение, а может просто упасть. Эти исключения принадлежат не к C++ исключениям, а к механизму Windows SEH. Я раскрою немного больше в следующей части статьи.
Сначала сообщение об исключении получает отладчик (first chance exception), это помогает ему обработать исключение быстрее ОП. Некоторые библиотеки генерируют исключения first-chance чтобы помочь отладчику делать его работу.

Ещё немного о ContinueDebugEvent

Третий параметр этой функции (dwContinueStatus) важен нам только получения исключения. Для остальных событий этот параметр игнорируется.
После получения исключения, ContinueDebugEvent должен быть вызван с:

  • DBG_CONTINUE, если исключение было успешно поймано отладчиком. От отлаживаемой программы больше ничего не надо и она может выполняться нормально.
  • DBG_EXCEPTION_NOT_HANDLED, если это исключение не обработано (не может быть обработано) отладчиком. Отладчик может лишь сделать запись о том, что это исключение было.

Обратите внимание, что если вернуть DBG_CONTINUE во время того события отладки, в котором это возвращать нельзя, то точно такое же исключение бросится в отладчике, и такое же событие придёт моментально. Но так как мы только начинаем писать отладчик, давайте будет играть с безопасной рогаткой, а не пистолетом, и будем возвращать EXCEPTION_NOT_HANDLED. Исключение в этой статье составляет int 3 (точка останова), которое мы обсудим позже.

Коды исключений

  1. EXCEPTION_ACCESS_VIOLATION
  2. EXCEPTION_ARRAY_BOUNDS_EXCEEDED
  3. EXCEPTION_BREAKPOINT
  4. EXCEPTION_DATATYPE_MISALIGNMENT
  5. EXCEPTION_FLT_DENORMAL_OPERAND
  6. EXCEPTION_FLT_DIVIDE_BY_ZERO
  7. EXCEPTION_FLT_INEXACT_RESULT
  8. EXCEPTION_FLT_INVALID_OPERATION
  9. EXCEPTION_FLT_OVERFLOW
  10. EXCEPTION_FLT_STACK_CHECK
  11. EXCEPTION_FLT_UNDERFLOW
  12. EXCEPTION_ILLEGAL_INSTRUCTION
  13. EXCEPTION_IN_PAGE_ERROR
  14. EXCEPTION_INT_DIVIDE_BY_ZERO
  15. EXCEPTION_INT_OVERFLOW
  16. EXCEPTION_INVALID_DISPOSITION
  17. EXCEPTION_NONCONTINUABLE_EXCEPTION
  18. EXCEPTION_PRIV_INSTRUCTION
  19. EXCEPTION_SINGLE_STEP
  20. EXCEPTION_STACK_OVERFLOW

Успокойтесь, я не собираюсь описывать их все. Только EXCEPTION_BREAKPOINT:

case EXCEPTION_DEBUG_EVENT:
{
   EXCEPTION_DEBUG_INFO& exception = debug_event.u.Exception;
   switch( exception.ExceptionRecord.ExceptionCode)
   {
      case STATUS_BREAKPOINT:  // Same value as EXCEPTION_BREAKPOINT

         strEventMessage= "Break point";
         break;

      default:
         if(exception.dwFirstChance == 1)
         {
            strEventMessage.Format(L"First chance exception at %x, exception-code: 0x%08x",
                        exception.ExceptionRecord.ExceptionAddress,
                        exception.ExceptionRecord.ExceptionCode);
         }        
         dwContinueStatus = DBG_EXCEPTION_NOT_HANDLED;
         }

         break;
}

Вам должно быть известно, что такое точка останова. Вне стандартной точки зрения, точку останова можно вызвать с помошью DebugBreak API, или с помощью инструкции ассемблера { int 3 }. В .NET её можно создать с помощью System.Diagnostics.Debugger.Break. Отладчик получит код STATUS_BREAKPOINT (такой же, как EXCEPTION_BREAKPOINT). Отладчик обычно использует это событие для остановки текущего процесса, и может показать исходный код того места, где произошло событие. Но так как у нас отладчик только начинает разрабатываться, то мы будем показывать пользователю только базовую информацию без исходного кода.
Если точка останова будет вызвана в приложении, которое не находится под отладчиком, то оно просто упадёт. Можно использовать следующую конструкцию:

if ( !IsDebuggerPresent() )
   AfxMessageBox(L"No debugger is attached currently.");
else
   DebugBreak();

В заключении, хотелось бы привести простейшее событие отладки: EXCEPTION_DEBUG_EVENT. Это событие будет приходить постоянно. Отладчики вроде Visual Studio игнорируют его, а WinDbg нет.

Заключение

Используйте любой отладчик для DebugMe.
Пишем свой отладчик под Windows [часть 1]
Вторая часть будет ещё интереснее и она на подходе!

Автор: NeonMercury

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js