понедельник, 19 ноября 2012 г.

Изучаем отладчик, часть вторая


В первой части статьи были рассмотрены некоторые нюансы работы  с интегрированным отладчиком Delphi - не все конечно, но наиболее необходимые разработчику. Теперь задача выглядит несколько иначе: рассмотрим его работу изнутри на примере его исходного кода. Для того чтобы не сильно утомлять вас описанием API функций и не разжевывать все этапы отладки, описывать его работу я буду на примере класса TFWDebugerCore. Часть не особо важных моментов я опущу, при желании вы их сможете уточнить просмотрев код данного класса.

Если вы уже знакомы с работой отладчика - ничего страшного, вполне вероятно что некоторые аспекты его работы, упомянутые в статье, будут вам интересны.

Если же вы ранее никогда не сталкивались с самостоятельной реализацией отладчика, но заинтересованы в ней, то как минимум вы должны начать с данной ссылки: Debugging and Error Handling
По ней вы сможете узнать об основных аспектах отладки, как-то структурной обработке исключений, работой с отладочной информацией, минидампами. Работой с образом исполняемого файла, заголовками, секциями, картой памяти процесса, что такое RVA и VA и прочее-прочее.
Но это только если захотите разобраться во всей этой кухне.

Я же попробую описать только часть ее более простым языком, дабы у вас была точка, от которой можно было-бы оттолкнуться, если вы вдруг заинтересовались, ну и конечно, если вы реализуете защиту приложения, то вникнуть в тонкости работы отладчика вам необходимо как минимум (а иначе-то как по другому?).

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

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




Начнем с присоединения отладчика к процессу:


Перед подключением к процессу самым первым действием необходимо получить отладочные привилегии. Делается это вот таким простым кодом:

  function SetDebugPriv: Boolean;
  var
    Token: THandle;
    tkp: TTokenPrivileges;
  begin
    Result := false;
    if OpenProcessToken(GetCurrentProcess,
      TOKEN_ADJUST_PRIVILEGES or TOKEN_QUERY, Token) then
    begin
      if LookupPrivilegeValue(nil, PChar('SeDebugPrivilege'),
        tkp.Privileges[0].Luid) then
      begin
        tkp.PrivilegeCount := 1;
        tkp.Privileges[0].Attributes := SE_PRIVILEGE_ENABLED;
        Result := AdjustTokenPrivileges(Token, False,
          tkp, 0, PTokenPrivileges(nil)^, PDWord(nil)^);
      end;
    end;
  end;

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

Если процесс уже запущен и мы захотели к нему присоединится,  то нам достаточно узнать только PID процесса и выполнить такой код:

function TFWDebugerCore.AttachToProcess(ProcessID: DWORD;
  SentEntryPointBreakPoint: Boolean): Boolean;
begin
  Result := False;
  if FProcessInfo.ProcessID <> 0 then Exit;
  FSetEntryPointBreakPoint := SentEntryPointBreakPoint;
  FProcessInfo.ProcessID := ProcessID;
  Result := DebugActiveProcess(ProcessID);
end;

Правда данный код не всегда выполнится успешно как минимум по двум причинам.

Дело в том что в Windows невозможно подключится к процессу двумя отладчиками одновременно, и если к необходимому нам процессу уже присоединен отладчик, то вызов функции DebugActiveProcess будет не успешен, а GetLastError вернет нам код ошибки: ERROR_INVALID_PARAMETER.

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

Во втором случае можно обойти данную ошибку запустив отладчик с требуемыми привилегиями.

Второй вариант присоединения отладчика к процессу, через запуск процесса вот таким кодом:

function TFWDebugerCore.DebugNewProcess(const FilePath: string;
  SentEntryPointBreakPoint: Boolean): Boolean;
var
  PI: TProcessInformation;
  SI: TStartupInfo;
begin
  Result := False;
  if FProcessInfo.ProcessID <> 0 then Exit;
  FSetEntryPointBreakPoint := SentEntryPointBreakPoint;
  ZeroMemory(@SI, SizeOf(TStartupInfo));
  SI.cb := SizeOf(TStartupInfo);
  Result := CreateProcess(PChar(FilePath),
    nil, nil, nil, False, DEBUG_PROCESS or DEBUG_ONLY_THIS_PROCESS,
    nil, nil, SI, PI);
  if Result then
  begin
    FProcessInfo.ProcessID := PI.dwProcessId;
    FProcessInfo.CreatedProcessHandle := PI.hProcess;
    FProcessInfo.CreatedThreadHandle := PI.hThread;
  end;
end;

Здесь все просто, в приведенном коде мы запустили процесс с флагом DEBUG_PROCESS и дополнительно указали флаг DEBUG_ONLY_THIS_PROCESS, указывая что отлаживать процессы, созданные отлаживаемым, мы не будем.
После запуска процесса, запоминаем его параметры (пригодятся).

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

Получить отладочное событие от отлаживаемого процесса мы можем при помощи вызова функции WaitForDebugEvent, после чего мы можем выполнить любые необходимые действия и вернуть ему управление посредством вызова функции ContinueDebugEvent, после чего опять мы должны ждать следующего события.
Т.е. грубо мы должны реализовать цикл обработки отладочных сообщений (Debug Event Loop).

MSND рекомендует нам следующую реализацию Debug Event Loop.

Writing the Debugger's Main Loop

Примерно такую же мы и реализуем в нашем отладчике.

procedure TFWDebugerCore.RunMainLoop;
var
  DebugEvent: TDebugEvent;
  CallNextLoopIteration: Boolean;
  ThreadIndex: Integer;
begin
  CallNextLoopIteration := False;
  repeat

    ContinueStatus := DBG_CONTINUE;

    if not WaitForDebugEvent(DebugEvent, MainLoopWaitPeriod) then
    begin
      if GetLastError = ERROR_SEM_TIMEOUT then
      begin
        DoIdle;
        if FProcessInfo.ProcessID = 0 then Exit;
        CallNextLoopIteration := True;
        Continue;
      end
      else
      begin
        DoMainLoopFailed;
        Break;
      end;
    end;
    
    case DebugEvent.dwDebugEventCode of

      CREATE_THREAD_DEBUG_EVENT:
        DoCreateThread(DebugEvent);

      CREATE_PROCESS_DEBUG_EVENT:
        DoCreateProcess(DebugEvent);

      EXIT_THREAD_DEBUG_EVENT:
        DoExitThread(DebugEvent);

      EXIT_PROCESS_DEBUG_EVENT:
      begin
        DoExitProcess(DebugEvent);
        Break;
      end;

      LOAD_DLL_DEBUG_EVENT:
        DoLoadDll(DebugEvent); 

      UNLOAD_DLL_DEBUG_EVENT:
        DoUnLoadDll(DebugEvent);

      OUTPUT_DEBUG_STRING_EVENT:
        DoDebugString(DebugEvent);

      RIP_EVENT:
        DoRip(DebugEvent);

      EXCEPTION_DEBUG_EVENT:
      begin
        ThreadIndex := GetThreadIndex(DebugEvent.dwThreadId);
        case DebugEvent.Exception.ExceptionRecord.ExceptionCode of

          EXCEPTION_BREAKPOINT:
            ProcessExceptionBreakPoint(ThreadIndex, DebugEvent);

          EXCEPTION_SINGLE_STEP:
            ProcessExceptionSingleStep(ThreadIndex, DebugEvent);

          EXCEPTION_GUARD_PAGE:
            ProcessExceptionGuardPage(ThreadIndex, DebugEvent);

        else
          CallUnhandledExceptionEvents(ThreadIndex, CodeDataToExceptionCode(
            DebugEvent.Exception.ExceptionRecord.ExceptionCode), DebugEvent);
        end;
      end;

    end;

    CallNextLoopIteration := ContinueDebugEvent(DebugEvent.dwProcessId,
      DebugEvent.dwThreadId, ContinueStatus);

  until not CallNextLoopIteration;
end;

В процессе отладки мы будем постоянно находится внутри данного цикла и обрабатывать известные нам события. Функция WaitForDebugEvent на каждой итерации отладочного цикла возвращает структуру DEBUG_EVENT. На основании параметра dwDebugEventCode данной структуры мы можем идентифицировать тип полученного события, ID процесса и нити в котором возникло событие, а также параметры каждого события, представленные в виде union  последним полем данной структуры:

  PDebugEvent = ^TDebugEvent;
  _DEBUG_EVENT = record
    dwDebugEventCode: DWORD;
    dwProcessId: DWORD;
    dwThreadId: DWORD;
    case Integer of
      0: (Exception: TExceptionDebugInfo);
      1: (CreateThread: TCreateThreadDebugInfo);
      2: (CreateProcessInfo: TCreateProcessDebugInfo);
      3: (ExitThread: TExitThreadDebugInfo);
      4: (ExitProcess: TExitProcessDebugInfo);
      5: (LoadDll: TLoadDLLDebugInfo);
      6: (UnloadDll: TUnloadDLLDebugInfo);
      7: (DebugString: TOutputDebugStringInfo);
      8: (RipInfo: TRIPInfo);
  end;
  {$EXTERNALSYM _DEBUG_EVENT}
  TDebugEvent = _DEBUG_EVENT;
  DEBUG_EVENT = _DEBUG_EVENT;
  {$EXTERNALSYM DEBUG_EVENT}

Каждому событию соответствует свой набор параметров, но на них мы остановимся немного попозже.

Если какое-то либо из событий нашим кодом не обрабатывается, просто продолжаем выполнение отлаживаемого процесса, вызовом функции ContinueDebugEvent выставив параметру ContinueStatus значение DBG_CONTINUE.

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

Пока что все достаточно просто, теперь посмотрим что дают нам события.

CREATE_PROCESS_DEBUG_EVENT:


Самое первое событие, которое получит отладчик при начале отладки. Не важно, запустили ли мы процесс самостоятельно, или присоединились к нему посредством вызова DebugActiveProcess, начнем работу мы именно с него. Параметры данного события хранятся в структуре DebugEvent.CreateProcessInfo (CREATE_PROCESS_DEBUG_INFO structure).

В общем виде обработчик данного события выглядит так:

procedure TFWDebugerCore.DoCreateProcess(DebugEvent: TDebugEvent);
begin

  // Сохраняем данные о процессе
  FProcessInfo.AttachedFileHandle := DebugEvent.CreateProcessInfo.hFile;
  FProcessInfo.AttachedProcessHandle := DebugEvent.CreateProcessInfo.hProcess;
  FProcessInfo.AttachedThreadHandle := DebugEvent.CreateProcessInfo.hThread;
  FProcessInfo.EntryPoint := DWORD(DebugEvent.CreateProcessInfo.lpStartAddress);

  AddThread(DebugEvent.dwThreadId, FProcessInfo.AttachedThreadHandle);

  // Устанавливаем BreakPoint на точку входа процесса
  if FSetEntryPointBreakPoint then
    SetBreakpoint(FProcessInfo.EntryPoint, 'Process Entry Point Breakpoint');

  if Assigned(FCreateProcess) then
  begin
    FCreateProcess(Self, GetThreadIndex(DebugEvent.dwThreadId),
      DebugEvent.CreateProcessInfo);
    DoResumeAction(GetThreadIndex(DebugEvent.dwThreadId));
  end;
end;

В нем мы просто запоминаем параметры процесса, а так-же добавляем ID и хэндл главной нити процесса в внутренний список. Эти данные нам пригодятся позднее.

Здесь-же мы можем определить точку входа процесса (Entry Point), ее значение записано в параметре DebugEvent.CreateProcessInfo.lpStartAddres и при желании установить точку остановки (далее ВР) на ее адрес и запустить процесс на выполнение. Если немного огрубить - то, выполнив данное действие мы проимитируем поведение отладчика Delphi при нажатии на кнопку F7.

Что такое точка входа: когда загрузчик создает процесс, до того момента, когда он запустится, выполняется очень много подготовительных действий. Создание главной нити приложения, настройка стеков, блоков окружения процесса/нитей, подгрузка библиотек, выполнение их TLS каллбэков и т.п. Только после того как все это будет выполнено, загрузчик передает управление непосредственно на точку входа, откуда уже начинает выполнение код, реализованный программистом. Адрес данной точки хранится непосредственно в заголовке PE файла, откуда ее можно получить любым приложением отображающем структуру PE файла, например PEiD или PeExplorer, ну или прочитать это значение самостоятельно, зачитав структуру TImageDosHeader, располагающуюся в самом начале файла, в ее поле _lfanew будет оффсет на начало TImageNtHeaders, после чего считать саму структуру TImageNtHeaders и посмотреть значение ее поля TImageNtHeaders.OptionalHeader.AddressOfEntryPoint.

Попробуйте скомпилировать пустой проект, и нажать в нем F7, после чего перейти на закладку CPU-View, должно получится примерно так:


Адрес точки входа получился: 0x0043E2D4. Теперь посмотрим, что нам скажет PEiD по поводу получившегося приложения:


Он говорит, что значение точки входа равно 0x0003E2D4.

Хоть оно и не совпадает с тем числом, которое мы увидели в отладчике, тем не менее здесь все верно, так как значение хранящееся в параметре AddressOfEntryPoint представлено в виде RVA (Relative Virtual Address). Особенность данной адресации в том, что в ней не учитывается адрес загрузки нашего модуля (hInstance). Для того чтобы из RVA адреса получить VA (Virtual Address) необходимо к нему прибавить hInstance модуля.

Есть нюанс: это справедливо только для приложений, для библиотек работает немного по другому. Для них приходится ориентироваться на адреса секций. Более подробно можно узнать в данном демо-примере: "Реализация закладки свойств файла".
В нем, в модуле DebugHlp.pas приведена реализация функции ImageRvaToVa(), по которой можно наглядно изучить правила приведения адресов.

Ну а для приложения базовый адрес загрузки всегда равен значению, указанному нами в настройках линкера в параметре Image Base, который по умолчанию равен 0x00400000. Сложив эти два числа мы как раз получим требуемый 0x0043E2D4.

LOAD_DLL_DEBUG_EVENT:


Сразу вслед за CREATE_PROCESS_DEBUG_EVENT мы начнем получать события о загрузке библиотек, с параметрами в структуре DebugEvent.LoadDll (LOAD_DLL_DEBUG_INFO structure).

В общем случае загрузку библиотек мы можем наблюдать в отладчике Delphi, который выводит уведомления о загрузке в лог событий:


При получении данного события Delphi отладчик, в случае установки ВР на загрузку модуля, прерывается сразу после его загрузки.


Мы так-же можем уведомить пользователя о загрузке модуля таким кодом:

procedure TFWDebugerCore.DoLoadDll(DebugEvent: TDebugEvent);
begin
  if Assigned(FLoadDll) then
  begin
    FLoadDll(Self, GetThreadIndex(DebugEvent.dwThreadId),
      DebugEvent.LoadDll);
    DoResumeAction;
  end;
  CloseHandle(DebugEvent.LoadDll.hFile);
end;

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

Нюанс заключается в следующем: адрес с путем к подгруженной библиотеке, хранящийся в параметре DebugEvent.LoadDll.lpImageName расположен не в нашем адресном пространстве, поэтому читать нам его придется через ReadProcessMemory.
Второй нюанс: это значение так-же является указателем на буфер по которому расположены данные о пути, т.е. читать придется как минимум два раза.
Третий нюанс: путь может быть как в Ansii так и в Unicode кодировке.
Ну и на закуску, четвертый нюанс: мы можем не прочитать данные :)

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

Рассмотрим реализацию.
Класс TFWDebugerCore нас уведомит о загрузке библиотеки вызовом внешнего события OnLoadDll, где мы напишем следующий код:

procedure TdlgDebuger.OnLoadDll(Sender: TObject; ThreadIndex: Integer;
  Data: TLoadDLLDebugInfo);
const
  FormatStrKnownDLL = 'Load Dll at instance %p handle %d "%s"';
  FormatStrUnknownDLL = 'Load unknown Dll at instance %p handle %d';
var
  DllName: AnsiString;
  IsUnicodeData: Boolean;
begin
  FCore.ContinueStatus := DBG_EXCEPTION_NOT_HANDLED;
  IsUnicodeData := Data.fUnicode = 1;
  DllName := FCore.GetDllName(Data.lpImageName, Data.lpBaseOfDll, IsUnicodeData);
  if DllName <> '' then
  begin
    if IsUnicodeData then
      Writeln(Format(FormatStrKnownDLL, [Data.lpBaseOfDll,
        Data.hFile, PWideChar(@DllName[1])]))
    else
      Writeln(Format(FormatStrKnownDLL, [Data.lpBaseOfDll,
        Data.hFile, PAnsiChar(@DllName[1])]));
  end
  else
    Writeln(Format(FormatStrUnknownDLL, [Data.lpBaseOfDll, Data.hFile]));
end;

Здесь, мы вызываем метод TFWDebugerCore.GetDllName() и (ориентируясь на параметр fUnicode) выводим данные на консоль.

Реализация метода GetDllName выглядит следующим образом:

function TFWDebugerCore.ReadData(AddrPrt, ResultPtr: Pointer; DataSize: Integer): Boolean;
var
  Dummy: DWORD;
begin
  Result := ReadProcessMemory(FProcessInfo.AttachedProcessHandle,
    AddrPrt, ResultPtr, DataSize, Dummy) and (Integer(Dummy) = DataSize);
end;

function TFWDebugerCore.ReadStringA(AddrPrt: Pointer;
  DataSize: Integer): AnsiString;
begin
  SetLength(Result, DataSize);
  if not ReadData(AddrPrt, @Result[1], DataSize) then
    Result := '';
end;

  function GetMappedFileNameA(hProcess: THandle; lpv: Pointer;
    lpFilename: LPSTR; nSize: DWORD): DWORD; stdcall; external 'psapi.dll';

function TFWDebugerCore.GetDllName(lpImageName, lpBaseOfDll: Pointer;
  var Unicode: Boolean): AnsiString;
var
  DllNameAddr: Pointer;
  MappedName: array [0..MAX_PATH - 1] of AnsiChar;
begin
  if ReadData(lpImageName, @DllNameAddr, 4) then
    Result := ReadStringA(DllNameAddr, MAX_PATH);
  if Result = '' then
  begin
    if GetMappedFileNameA(FProcessInfo.AttachedProcessHandle,
      lpBaseOfDll, @MappedName[0], MAX_PATH) > 0 then
    begin
      Result := PAnsiChar(@MappedName[0]);
      Unicode := False;
    end;
  end;
end;

То есть, сначала мы пробуем получить путь к библиотеке, читая данные из адресного пространства отлаживаемого процесса (ReadData + ReadStringA), а если не получилось, берем эти данные при помощи вызова функции GetMappedFileNameA. Она возвращает данные с использованием символьных ссылок, поэтому результат по хорошему нужно еще привести к нормальному пути, но в данном случае я этого не стал делать, дабы не переусложнять код.

CREATE_THREAD_DEBUG_EVENT


Данное событие мы получим в тот момент, когда в отлаживаемом приложении будет создана новая нить. Параметры данного события хранятся в структуре DebugEvent.CreateThread (CREATE_THREAD_DEBUG_INFO structure).

Из всех параметров нас больше всего интересует DebugEvent.CreateThread.hThread, который желательно сохранить во внутреннем списке.

Нюанс в том, что большинство событий содержит в себе данные только об ID нити, и когда мы захотим с ней поработать (например установить Hardware Breakpoint), придется делать вызов OpenThread по переданному ID. Чтобы не утруждать себя данными действиями, будем держать пары ThreadID = ThreadHandle в собственном кэше.

Код обработчика данного события следующий:

procedure TFWDebugerCore.DoCreateThread(DebugEvent: TDebugEvent);
begin
  AddThread(DebugEvent.dwThreadId, DebugEvent.CreateThread.hThread);
  if Assigned(FCreateThread) then
  begin
    FCreateThread(Self, GetThreadIndex(DebugEvent.dwThreadId),
      DebugEvent.CreateThread);
    DoResumeAction;
  end;
end;

Кроме сохранения параметров нити и вызова внешнего обработчика в нем ничего нет.

OUTPUT_DEBUG_STRING_EVENT:


Событие генерируется в тот момент, когда отлаживаемое приложение пытается на что-то сообщить вызовам функции OutputDebugString. Параметры данного события хранятся в структуре DebugEvent.DebugString (OUTPUT_DEBUG_STRING_INFO structure).

Обработчик события простой:

procedure TFWDebugerCore.DoDebugString(DebugEvent: TDebugEvent);
begin
  if Assigned(FDebugString) then
  begin
    FDebugString(Self, GetThreadIndex(DebugEvent.dwThreadId),
      DebugEvent.DebugString);
    DoResumeAction;
  end;
end;

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

Например вот так:

procedure TdlgDebuger.OnDebugString(Sender: TObject; ThreadIndex: Integer;
  Data: TOutputDebugStringInfo);
begin
  if Data.fUnicode = 1 then
    Writeln('DebugString: ' + PWideChar(FCore.ReadStringW(Data.lpDebugStringData,
      Data.nDebugStringLength)))
  else
    Writeln('DebugString: ' + PAnsiChar(FCore.ReadStringA(Data.lpDebugStringData,
      Data.nDebugStringLength)));
end;

В обработчике смотрим в какой кодировке нам передается буфер, ориентируясь на параметр Data.fUnicode и вызываем соответствующую функцию ядра отладчика ReadStringХ().

UNLOAD_DLL_DEBUG_EVENT, EXIT_THREAD_DEBUG_EVENT, EXIT_PROCESS_DEBUG_EVENT, RIP_EVENT:


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

EXCEPTION_DEBUG_EVENT:


Все восемь вышеперечисленных события в принципе второстепенны. Основная работа начинается только после прихода события EXCEPTION_DEBUG_EVENT.
Его параметры идут в структуре DebugEvent.Exception (EXCEPTION_DEBUG_INFO structure).

Генерация данного события означает что в отлаживаем приложении произошло некое исключение, тип которого можно узнать из параметра DebugEvent.Exception.ExceptionRecord.ExceptionCode. Помните, в первой части статьи я упоминал что отладка производится через механизм структурной обработки ошибок (SEH)? Вот сейчас мы это и рассмотрим более подробно.

Большинство исключений в процессе отладки являются наведенными. То есть получение исключения не означает что в самой программе произошла ошибка. Скорее всего исключение произошло из-за вмешательства отладчика в работу приложения, например посредством установки ВР.

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

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

  1. Стандартный ВР на строчку кода.
  2. ВР на адрес памяти (Memory Breakpoint или урезанный Data Preakpoint в Delphi).
  3. Hardware BP (отсутствует в Delphi).

Для работы с ними достаточно обработки трех типов исключений:

      EXCEPTION_DEBUG_EVENT:
      begin
        ThreadIndex := GetThreadIndex(DebugEvent.dwThreadId);
        case DebugEvent.Exception.ExceptionRecord.ExceptionCode of

          EXCEPTION_BREAKPOINT:
            ProcessExceptionBreakPoint(ThreadIndex, DebugEvent);

          EXCEPTION_SINGLE_STEP:
            ProcessExceptionSingleStep(ThreadIndex, DebugEvent);

          EXCEPTION_GUARD_PAGE:
            ProcessExceptionGuardPage(ThreadIndex, DebugEvent);

        else
          CallUnhandledExceptionEvents(ThreadIndex, CodeDataToExceptionCode(
            DebugEvent.Exception.ExceptionRecord.ExceptionCode), DebugEvent);
        end;
      end;

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

Реализация точки остановки на строчку кода:


Установка ВР на строку кода производится при помощи модификации кода отлаживаемого приложения. Классически это делается записью опкода 0xCC по адресу устанавливаемой ВР, означающего инструкцию "INT3".

Встречаются и другие варианты, например опкод 0xCD03, так же представляющий из себя инструкцию "INT3". Второй вариант больше применяется при антиотладке и устанавливается в большинстве случаев самим приложением, пытаясь поймать наличие отладчика на том, что ядерный _KiTrap03() умеет работать только с однобайтовым опкодом и немного неверно обрабатывает двухбайтовый.

Но, все это лирика, нас интересует именно первый опкод.

Для хранения списка установленных ВР класс TFWDebugerCore использует следующие структуры:

  // Список поддерживаемых типов точек остановки (далее ВР)

  TBreakpointType = (
    btBreakpoint,           // WriteProcessMemoryEx + 0xCC
    btMemoryBreakpoint      // VirtualProtectEx + PAGE_GUARD
  );

  // структуры для хранения данных об известных отладчику ВР

  TInt3Breakpoint = record
    Address: Pointer;
    ByteCode: Byte;
  end;

  TMemotyBreakPoint = record
    Address: Pointer;
    Size: DWORD;
    BreakOnWrite: Boolean;
    RegionStart: Pointer;
    RegionSize: DWORD;
    PreviosRegionProtect: DWORD;
  end;

  TBreakpoint = packed record
    bpType: TBreakpointType;
    Description: ShortString;
    Active: Boolean;
    case Integer of
      0: (Int3: TInt3Breakpoint;);
      1: (Memory: TMemotyBreakPoint);
  end;

  TBreakpointList = array of TBreakpoint;

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

Для ВР на строку кода нам необходимо хранить только два значения, адрес ВР и значение байта, хранящегося по данному адресу перед тем как мы затрем его опкодом 0xCC.

Установка ВР в отлаживаемом приложении выглядит примерно таким образом:

function TFWDebugerCore.SetBreakpoint(Address: DWORD;
  const Description: string): Boolean;
var
  Breakpoint: TBreakpoint;
  OldProtect: DWORD;
  Dummy: DWORD;
begin
  ZeroMemory(@Breakpoint, SizeOf(TBreakpoint));
  Breakpoint.bpType := btBreakpoint;
  Breakpoint.Int3.Address := Pointer(Address);
  Breakpoint.Description := Description;
  Check(VirtualProtectEx(FProcessInfo.AttachedProcessHandle,
    Pointer(Address), 1, PAGE_READWRITE, OldProtect));
  try
    Check(ReadProcessMemory(FProcessInfo.AttachedProcessHandle,
      Pointer(Address), @Breakpoint.Int3.ByteCode, 1, Dummy));
    Check(WriteProcessMemory(FProcessInfo.AttachedProcessHandle,
      Pointer(Address), @BPOpcode, 1, Dummy));
  finally
    Check(VirtualProtectEx(FProcessInfo.AttachedProcessHandle,
      Pointer(Address), 1, OldProtect, OldProtect));
  end;
  Result := AddNewBreakPoint(Breakpoint);
end;

Первоначально производится инициализация структуры, устанавливается тип ВР, его описание и параметры. Так как производится запись в область кода, которая обычно не имеет прав на запись, выставляются соответствующие права, читается оригинальное значение, расположенное по адресу ВР, пишется инструкция 0xCC представленная константой BPOpcode и возвращаются изначальные атрибуты страницы повторным вызовом VirtualProtectEx(). В завершении всего, если не произошло ошибок, запись об установленном ВР помещается в общий список класса.

Ну а теперь начинается самое интересное:

После установки ВР отлаживаемое приложение продолжит свое нормальное функционирование, пока не произойдет переход на записанную нами инструкцию "INT3". В этот момент будет сгенерировано событие EXCEPTION_DEBUG_EVENT с кодом исключения EXCEPTION_BREAKPOINT.

Параметры исключения будут переданы нам в виде структуры DebugEvent.Exception.ExceptionRecord (EXCEPTION_DEBUG_INFO structure).

Как я и описывал ранее, ВР может быть установлено и самим отлаживаемым приложением, поэтому ориентируясь на данные параметры необходимо разобраться, что за ВР сработал?

Для этого нам пригодится список ранее сохраненных точек остановки. Пробежавшись в цикле по нему и сравнив адрес хранящийся в параметре DebugEvent.Exception.ExceptionRecord.ExceptionAddress с полем Address каждой записи с типом btBreakpoint, мы можем определить, устанавливали ли мы по данному адресу ВР или это что-то не наше.

Если мы определили что ВР действительно наш, то поднимаем внешнее событие (дабы показать пользователю что мы не просто так тут, а даже работаем) и после его обработки приступаем к восстановлению отлаживаемого приложения.

Последствия установки ВР:


Устанавливая ВР мы затерли часть исполняемого кода.

Для примера изначально был такой код:


После проведенных нами манипуляций он превратился в следующее:


Первым шагом мы должны восстановить значение изначальной инструкции.

Чтобы это было сделать более удобно, у структуры TBreakpoint есть параметр Active, указывающий на состояние точки остановки. Ориентируясь на данный параметр  класс TFWDebugerCore знает о их активности, а для переключения состояния он реализует метод ToggleInt3Breakpoint, в котором в зависимости от флага включает и отключает ВР возвращая на место затертый байт.

procedure TFWDebugerCore.ToggleInt3Breakpoint(Index: Integer;
  Active: Boolean);
var
  OldProtect: DWORD;
  Dummy: DWORD;
begin
  CheckBreakpointIndex(Index);
  if FBreakpointList[Index].bpType <> btBreakpoint then Exit;
  if FBreakpointList[Index].Active = Active then Exit;
  Check(VirtualProtectEx(FProcessInfo.AttachedProcessHandle,
    FBreakpointList[Index].Int3.Address, 1, PAGE_READWRITE, OldProtect));
  try
    if Active then
      Check(WriteProcessMemory(FProcessInfo.AttachedProcessHandle,
        FBreakpointList[Index].Int3.Address, @BPOpcode, 1, Dummy))
    else
      Check(WriteProcessMemory(FProcessInfo.AttachedProcessHandle,
        FBreakpointList[Index].Int3.Address,
        @FBreakpointList[Index].Int3.ByteCode, 1, Dummy));
  finally
    Check(VirtualProtectEx(FProcessInfo.AttachedProcessHandle,
      FBreakpointList[Index].Int3.Address, 1, OldProtect, OldProtect));
  end;
  FBreakpointList[Index].Active := Active;
end;

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

Теперь нюанс: если мы прямо сейчас запустим отлаживаемое приложение на выполнение, то получим ошибку. А все потому что инструкция "INT3" уже выполнилась, и даже если мы вернули затертый нами байт на место по адресу 0x452220, отлаживаемая программа продолжит выполнение с адреса 0x452221, где расположена инструкция "mov ebp, esp", а не с того места где было сгенерировано отладочное исключение.

Второй нюанс: можно конечно помечтать в таком направлении: "Ну подумаешь - не выполнился "push ebp", ну уплыл стек, но мы же отладчик и если что - то можем прийти и поправить все ©". Так-то это в принципе правильно, за исключением одного момента.

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

Что это такое:


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

Как пример, достаточно малому количеству разработчиков известно такое понятие как "длинный NOP". Более того, года полтора-два назад его не было и в интеловских мануалах, где было сказано что NOP (пустая инструкция использующаяся для выравнивания кода) выглядит только в виде опкода 0x90. Соответственно большинство, дизассемблеров умеют работать только с данным опкодом. Да впрочем почему большинство - я еще не встречал дизассемблера правильно распознающего длинные нопы.

Раз дизассемблер не может его нормально распознать, то мы можем провернуть следующий трюк:

Например возьмем трехбайтный NOP (опкод $0F, $1F, $00) и напишем такой код:

 asm
   db $0F, $1F, $00
   xor eax, eax
   inc eax
   neg eax
 end;

А вот то что нам покажет дизассемблер:


Опкоды всех инструкций верные и выполнятся они верно, но дизассемблер показывает нам совершенно не то что мы написали изначально.

Ну или второй пример, уже непосредственно с применением прыжка. Здесь идея кроется в том, что перед началом очередной инструкции пишется абсолютно левый байт, перед которым пишется код инструкции "jmp +1" заставляя программу пропускать этот мусорный байт и перейти непосредственно на нужный код. Казалось бы банальная вещь - но достаточно сильно сбивает дизассемблер с толку.

Как пример, напишем следующий код:

    asm
       db $EB, $01   // jmp +1 (прыжок на xor пропуская "левый байт")
       db $B8        // непосредственно сам "левый" байт
       xor eax, eax  // правильный код
       inc eax
       neg eax
       not eax
       sub edx, eax
       imul eax, edx
       nop
       nop
    end;

А теперь посмотрим что нам покажет дизассемблер:


Полный мусор, что и ожидалось.

Так вот, для чего я все это рассказал: вернемся к нашему ВР и представим что он был установлен не по адресу 0x452220, а на девять байтов дальше, как раз в начало инструкции "push $00452245".

Эта инструкция, в отличие от однобайтовой "push epb" представлена в виде пяти байт. Даже после того как произойдет восстановление значения затертого байта, если мы продолжим выполнение кода, то мы начнем не с начала инструкции, а с ее середины, и тут уже ошибется не дизассемблер, а непосредственно отладчик, потому что для него этот код теперь будет выглядеть следующим образом:


Т.е. вместо оригинальной "push $00452245" будут выполнены инструкции "inc epb" и "and al, [ebp+$00]", которых здесь и рядом не стояло. А через пару-тройку инструкций мы получим то, что и ожидалось - ошибку.

Поэтому возвращение затертого байта на место не достаточно, необходимо заставить отлаживаемое приложение продолжить выполнение программы с правильного адреса. Адрес инструкции, которая должна быть выполнена на следующей итерации, хранит регистр EIP (Extended Instruction Pointer). Доступ к данному регистру осуществляется через контекст нити отлаживаемого процесса, в которой произошло прерывание. Для изменения значения регистра EIP необходимо получить контекст вызовом функции GetThreadContext, уменьшить текущее значение параметра Context.Eip, после чего записать новое значение вызовом функции SetThreadContext.

Есть нюанс: при возникновении события EXCEPTION_DEBUG_EVENT нам приходит только ID нити в параметре DebugEvent.dwThreadId, а функции GetThreadContext() и SetThreadContext() требуют для своей работы хэндл нити, ID им не интересен. Можно конечно получить требуемое значение вызовом функции OpenThread, но в данном случае это делать нам не придется, ведь у нас есть сохраненный список хэндлов нитей, хранимый в виде ThreadID = ThreadHandle.

Теперь мы вроде бы сделали все правильно, восстановили затертый байт, выставили правильный адрес инструкции и можем даже запускать программу на выполнение, но есть еще одно НО. А что делать с ранее установленным ВР, ведь после того как мы удалили опкод 0xCC запись о ВР осталось только в списках отладчика, а в отлаживаемом приложении его по факту нет? Если мы прямо сейчас запустим программу на выполнение, она выполнит текущую инструкцию и не останавливаясь перейдет к следующей, продолжая выполнять код программы до тех пор, пока не упрется в какой нибудь другой ВР или в ошибку.

Значит появилась задача, надо каким-то образом заставить приложение передать управление отладчику сразу после завершения выполнения инструкции, с которой мы только что сняли ВР. Если у нас это получится, то мы сможем вернуть ВР на свое законное место.

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

А правильным решением в данном случае будет перевод процессора в режим трассировки.
За включение данного режиме отвечает TF флаг процессора. Если данный флаг включен, при выполнении каждой инструкции будет вызвано прерывание "INT1", которое вызовет исключение в отлаживаемом процессе и отладчик получит управление по событию EXCEPTION_DEBUG_EVENT с кодом исключения EXCEPTION_SINGLE_STEP.

Включить данный флаг можно так же через контекст нити, через который мы меняли значение регистра EIP. За состояние флагов отвечает параметр Context.EFlags. Флаг TF хранится в восьмом бите данного параметра. Т.е. упрощая для его включения мы должны выполнить примерно следующее:

const
  EFLAGS_TF = $100; // 8-ой бит
...
  Context.EFlags := Context.EFlags or EFLAGS_TF;

Нюанс трассировки: Особенность прерывания "INT1" в том что оно сбрасывает TF флаг. Т.е. если нам нужно выполнить только одну инструкцию для восстановления ВР, данное поведение нам подходит идеально, ибо не нужно заботится о восстановлении TF флага в изначальное состояние. А вот если нас интересует последовательная трассировка каждой инструкции, то придется самостоятельно, в каждом обработчике EXCEPTION_SINGLE_STEP заново взводить TF флаг. Этот режим рассмотрим позже.

В итоге резюмируя, алгоритм установки и обработки ВР на адрес кода выглядит следующим образом:

  • Инициализировать структуру описывающую ВР, сохранить в ней адрес и значение байта по данному адресу.
  • Записать опкод 0xCC по адресу ВР и запустить программу на выполнение.
  • Дождаться исключения EXCEPTION_BREAKPOINT, при получении которого восстановить оригинальный байт
  • Изменить значение регистра EIP и перевести процессор в режим трассировки включением флага TF
  • Дождаться исключения EXCEPTION_SINGLE_STEP 
  • Вернуть опкод 0xCC на место.

Вот теперь вы имеете минимальное представление сколько действий на самом деле происходит при нажатии кнопки F7 в отладчике Delphi :)

Ну а вот так это реализовано в классе TFWDebugerCore.

Обработчик исключения EXCEPTION_BREAKPOINT:

procedure TFWDebugerCore.ProcessExceptionBreakPoint(ThreadIndex: Integer;
  DebugEvent: TDebugEvent);
var
  ReleaseBP: Boolean;
  BreakPointIndex: Integer;
begin
  ReleaseBP := False;
  BreakPointIndex := GetBPIndex(
    DWORD(DebugEvent.Exception.ExceptionRecord.ExceptionAddress));
  if BreakPointIndex >= 0 then
  begin
    if Assigned(FBreakPoint) then
      FBreakPoint(Self, ThreadIndex,
        DebugEvent.Exception.ExceptionRecord, BreakPointIndex,
        ReleaseBP)
    else
      CallUnhandledExceptionEvents(ThreadIndex, ecBreakpoint, DebugEvent);

    ToggleInt3Breakpoint(BreakPointIndex, False);
    SetSingleStepMode(ThreadIndex, True);
    if ReleaseBP then
      RemoveBreakpoint(BreakPointIndex)
    else
      FRestoreBPIndex := BreakPointIndex;
  end
  else
    CallUnhandledExceptionEvents(ThreadIndex, ecBreakpoint, DebugEvent);
end;

В нем сначала ищем индекс ВР во внутреннем списке по параметру ExceptionAddress.
Поднимаем внешнее событие.
Выключаем ВР вызовом метода ToggleInt3Breakpoint.
Правим EIP и включаем трассировку вызовом метода SetSingleStepMode.
Если пользователь в обработчике внешнего события сказал что хочет снять данное ВР - удаляем его вызовом RemoveBreakpoint.
Ну а если нужно просто продолжить выполнение, то запоминаем индекс текущего ВР для обработчика EXCEPTION_SINGLE_STEP, где он ориентируясь на данную переменную восстановит ВР в отлаживаемом процессе.

Код метода SetSingleStepMode выглядит следующим образом:

procedure TFWDebugerCore.SetSingleStepMode(ThreadIndex: Integer;
  RestoreEIPAfterBP: Boolean);
var
  Context: TContext;
begin
  ZeroMemory(@Context, SizeOf(TContext));
  Context.ContextFlags := CONTEXT_FULL;
  Check(GetThreadContext(FThreadList[ThreadIndex].ThreadHandle, Context));
  if RestoreEIPAfterBP then
    Dec(Context.Eip);
  Context.EFlags := Context.EFlags or EFLAGS_TF;
  Check(SetThreadContext(FThreadList[ThreadIndex].ThreadHandle, Context));
end;

Тут все очень просто, получаем полный контекст нити, выставив флаг CONTEXT_FULL.
По необходимости правим регистр EIP
Включаем TF флаг.
И назначаем новый контекст.

Метод RemoveBreakpoint еще проще:

procedure TFWDebugerCore.RemoveBreakpoint(Index: Integer);
var
  Len: Integer;
begin
  ToggleBreakpoint(Index, False);
  Len := BreakpointCount;
  if Len = 1 then
    SetLength(FBreakpointList, 0)
  else
  begin
    FBreakpointList[Index] := FBreakpointList[Len - 1];
    SetLength(FBreakpointList, Len - 1);
  end;
end;

Здесь просто выключается ВР после чего данные о ВР удаляются из списка отладчика.

Код обработчика исключения EXCEPTION_SINGLE_STEP я пока приводить не буду, т.к. он используется не только для восстановления ВР. Его покажу в самом конце статьи, когда будут рассмотрены все нюансы.

Реализация ВР на адрес памяти:


Следующий тип ВР применяется для контроля изменений данных в памяти отлаживаемого приложения. Более известен как Memory Breakpoint (далее MBP).

Реализуется он следующим образом: вся память приложения представлена в виде набора страниц, которые можно перечислить и получить их атрибуты. (см. демо-приложение: Карта памяти процесса). Когда мы хотим поставить MBP на какой либо адрес нам нужно вычислить границы страницы, к которой этот адрес принадлежит и выставить ей флаг PAGE_GUARD вызовом функции VirtualProtectEx.

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

Второй нюанс: большинство отладчиков не дают ставить одновременно два и более MBP расположенных в пределах одной станицы. Обусловлено это поведение скорее всего следующим моментом: при установке MBP необходимо запомнить текущее состояние поля Protect страницы, для того чтобы вернуть его обратно, когда придет время снимать MBP. В том случае если на странице уже установлен MBP атрибуты ее защиты изменены. Дабы обойти этот момент в классе TFWDebugerCore применяется следующий подход. При установке нового MBP сначала проверяется, есть ли еще один MBP контролирующий данную страницу. Если таковой находится, у него берется значение параметра PreviosRegionProtect, если же МВР отсутствует, данное значение получается посредством вызова VirtualProtectEx.

Код установки МВР выглядит так:

function TFWDebugerCore.SetMemoryBreakpoint(Address: Pointer; Size: DWORD;
  BreakOnWrite: Boolean; const Description: string): Boolean;
var
  Breakpoint: TBreakpoint;
  MBI: TMemoryBasicInformation;
  Index: Integer;
begin
  Index := GetMBPIndex(DWORD(Address));
  if (Index >= 0) and (FBreakpointList[Index].bpType = btMemoryBreakpoint) then
  begin
    MBI.BaseAddress := FBreakpointList[Index].Memory.RegionStart;
    MBI.RegionSize := FBreakpointList[Index].Memory.RegionSize;
    MBI.Protect := FBreakpointList[Index].Memory.PreviosRegionProtect;
  end
  else
    Check(VirtualQueryEx(DebugProcessData.AttachedProcessHandle,
      Address, MBI, SizeOf(TMemoryBasicInformation)) > 0);
  ZeroMemory(@Breakpoint, SizeOf(TBreakpoint));
  Breakpoint.bpType := btMemoryBreakpoint;
  Breakpoint.Description := ShortString(Description);
  Breakpoint.Memory.Address := Address;
  Breakpoint.Memory.Size := Size;
  Breakpoint.Memory.BreakOnWrite := BreakOnWrite;
  Breakpoint.Memory.RegionStart := MBI.BaseAddress;
  Breakpoint.Memory.RegionSize := MBI.RegionSize;
  Check(VirtualProtectEx(FProcessInfo.AttachedProcessHandle,
    Address, Size, MBI.Protect or PAGE_GUARD,
    Breakpoint.Memory.PreviosRegionProtect));
  if Index >= 0 then
    Breakpoint.Memory.PreviosRegionProtect := MBI.Protect;
  Result := AddNewBreakPoint(Breakpoint);
end;

Для MBP требуется хранить гораздо больше параметров, в отличие от ВР. Помимо адреса контролируемой области и ее размера, хранится параметр конфигурирующий сам МВР - BreakOnWrite, отвечающий за условия вызова внешнего события (при чтении данных с контролируемой области или при записи в нее). Так же хранится адрес начала страницы и ее размер.

После установки МВР можно запускать программу на выполнение. Как только произойдет доступ к контролируемой странице отладчику будет сгенерированно событие EXCEPTION_DEBUG_EVENT с кодом исключения EXCEPTION_GUARD_PAGE.

Здесь так-же есть нюанс. Когда мы выставляем флаг PAGE_GUARD странице, при первом к ней обращении поднимается исключение и этот флаг снимается. То есть в отличие от ВР самостоятельно заниматься отключением МВР нам не потребуется. Но есть небольшая проблема. Как я и говорил ранее, исключение EXCEPTION_GUARD_PAGE произойдет при любом обращении к странице, не только к адресу, по которому установлен МВР, поэтому отладчик должен уметь восстанавливать флаг PAGE_GUARD чтобы установленные МВР продолжали корректно работать.

Код обработчика выглядит так:

procedure TFWDebugerCore.ProcessExceptionGuardPage(ThreadIndex: Integer;
  DebugEvent: TDebugEvent);
var
  CurrentMBPIndex: Integer;

  function CheckWriteMode: Boolean;
  begin
    Result := not FBreakpointList[CurrentMBPIndex].Memory.BreakOnWrite;
    if not Result then
      Result := DebugEvent.Exception.ExceptionRecord.ExceptionInformation[0] = 1;
  end;

var
  MBPIndex: Integer;
  ReleaseMBP: Boolean;
  dwGuardedAddr: DWORD;
begin
  ReleaseMBP := False;
  dwGuardedAddr :=
    DebugEvent.Exception.ExceptionRecord.ExceptionInformation[1];
  MBPIndex := GetMBPIndex(dwGuardedAddr);
  if MBPIndex >= 0 then
  begin
    CurrentMBPIndex := MBPIndex;
    while not CheckIsAddrInRealMemoryBPRegion(CurrentMBPIndex, dwGuardedAddr) do
    begin
      CurrentMBPIndex := GetMBPIndex(dwGuardedAddr, CurrentMBPIndex + 1);
      if CurrentMBPIndex < 0 then Break;
    end;

    if CurrentMBPIndex >= 0 then
    begin
      MBPIndex := CurrentMBPIndex;
      if Assigned(FBreakPoint) and CheckWriteMode then
        FBreakPoint(Self, ThreadIndex,
          DebugEvent.Exception.ExceptionRecord, MBPIndex, ReleaseMBP)
      else
        CallUnhandledExceptionEvents(ThreadIndex, ecGuard, DebugEvent);
    end
    else
      CallUnhandledExceptionEvents(ThreadIndex, ecGuard, DebugEvent);

    FBreakpointList[MBPIndex].Active := False;
    SetSingleStepMode(ThreadIndex, False);
    if ReleaseMBP then
      RemoveBreakpoint(MBPIndex)
    else
      FRestoreMBPIndex := MBPIndex;
  end
  else
    CallUnhandledExceptionEvents(ThreadIndex, ecGuard, DebugEvent);
end;

Первоначально отладчик получает адрес к которому произошло обращение из-за которого произошло исключение. Этот адрес хранится в массиве ExceptionRecord.ExceptionInformation вторым параметром, первым параметром в данном массиве идет флаг операции. Ноль означает попытку чтения по адресу, единица - попытку записи по адресу.
Далее ищется подходящий МВР при помощи вызова CheckIsAddrInRealMemoryBPRegion который проверяет входит ли адрес в контролируемую МВР зону.

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

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

Восстановление снятого МВР по аналогии происходит в обработчике EXCEPTION_SINGLE_STEP на основе индекса FRestoreMBPIndex.

За переключение активности МВР отвечает следующий код:

procedure TFWDebugerCore.ToggleMemoryBreakpoint(Index: Integer;
  Active: Boolean);
var
  Dummy: DWORD;
begin
  CheckBreakpointIndex(Index);
  if FBreakpointList[Index].bpType <> btMemoryBreakpoint then Exit;
  if FBreakpointList[Index].Active = Active then Exit;
  if Active then
    Check(VirtualProtectEx(FProcessInfo.AttachedProcessHandle,
      FBreakpointList[Index].Memory.Address,
      FBreakpointList[Index].Memory.Size,
      FBreakpointList[Index].Memory.PreviosRegionProtect or PAGE_GUARD, Dummy))
  else
    Check(VirtualProtectEx(FProcessInfo.AttachedProcessHandle,
      FBreakpointList[Index].Memory.Address,
      FBreakpointList[Index].Memory.Size,
      FBreakpointList[Index].Memory.PreviosRegionProtect, Dummy));
  FBreakpointList[Index].Active := Active;
end;

Удаление МВР производится тем-же методом что и ВР.

В принципе каких-то сильных кардинальных различий, за исключением пары нюансов, у ВР и МВР нет. Они просто применяются каждый для своих задач.

Например, иногда МВР применяется в качестве трассировщика. Для осуществления данной возможности просто выставляется МБР на область кода и после запуска отладчика нам начинают приходить уведомлении о изменении текущего EIP в удаленном приложении. Достаточно удобная вещь, жаль не осуществимая в Delphi отладчике.

Пример такого использования я покажу в конце статьи. А сейчас перейдем к третьему и последнему типу точек остановки.

Реализация Аппаратных точек остановки:


Так называемые Hardware BreakPoint (далее HBP). Достаточно мощный инструмент отладки. В отличие от ВР и МВР данные точки остановки не производят модификаций в памяти отлаживаемого приложения. Плохо только то, что их очень мало, всего лишь  четыре штуки на каждую нить.

Но в отличии от других НВР предоставляет достаточно гибкие условия контроля отлаживаемого приложение.

Сравним:
ВР - отслеживает только обращения к исполняемому коду (скажем режим Исполнения)
МВР - позволяет отслеживать доступ в режиме Чтения или Чтения/Записи.
НВР - позволяет выставить условие более точно, он различает режимы Записи, Чтения/Записи, IO режим (доступ к порту ввода/вывода) и режим Исполнения.

Т.е. НВР может проэмулировать работу как ВР (в режиме Исполнение) так и МВР (в режимах Запись - Чтение/Запись). Правда в отличие от МВР он не может контролировать большой диапазон области памяти, т.к. умеет работать только с  блоками фиксированного размера 1, 2 или 4 байта.

Настройки НВР хранятся в контексте каждой нити, для этого используются DR регистры, доступ к которым осуществляется при указании флага CONTEXT_DEBUG_REGISTERS.
Всего их шесть. Dr0..Dr3, Dr6, Dr7. (Dr4 и Dr5 зарезервированы).
Первые 4 регистра хранят в себе адрес каждого из НВР. Регистр Dr7 используется для точной настройки параметров каждого из НВР. Регистр Dr6 служит для чтения результатов после срабатывания любой из четырех НВР.

Класс TFWDebugerCore хранит информацию о НВР в виде следующей структуры:

  THWBPIndex = 0..3;
  THWBPSize = (hsByte, hdWord, hsDWord);
  THWBPMode = (hmExecute, hmWrite, hmIO, hmReadWrite);

  THardwareBreakpoint = packed record
    Address: array [THWBPIndex] of Pointer;
    Size: array [THWBPIndex] of THWBPSize;
    Mode: array [THWBPIndex] of THWBPMode;
    Description: array [THWBPIndex] of ShortString;
    Active: array [THWBPIndex] of Boolean;
  end;

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

  TThreadData = record
    ThreadID: DWORD;
    ThreadHandle: THandle;
    Breakpoint: THardwareBreakpoint;
  end;
  TThreadList = array of TThreadData;

Т.е. помимо этих двух параметров у каждой нити присутствует своя структура, описывающая настройки принадлежащих ей НВР.

Работа с установкой, изменением состояния и удалением НВР реализована крайне просто.

Установка выглядит так:

procedure TFWDebugerCore.SetHardwareBreakpoint(ThreadIndex: Integer;
  Address: Pointer; Size: THWBPSize; Mode: THWBPMode;
  HWIndex: THWBPIndex; const Description: string);
begin
  if ThreadIndex < 0 then Exit;
  FThreadList[ThreadIndex].Breakpoint.Address[HWIndex] := Address;
  FThreadList[ThreadIndex].Breakpoint.Size[HWIndex] := Size;
  FThreadList[ThreadIndex].Breakpoint.Mode[HWIndex] := Mode;
  FThreadList[ThreadIndex].Breakpoint.Description[HWIndex] :=
    ShortString(Description);
  FThreadList[ThreadIndex].Breakpoint.Active[HWIndex] := True;
  UpdateHardwareBreakpoints(ThreadIndex);
end;

Просто инициализируем структуру и вызываем метод UpdateHardwareBreakpoints.

Модификация состояния реализована следующим кодом:

procedure TFWDebugerCore.ToggleHardwareBreakpoint(ThreadIndex: Integer;
  Index: THWBPIndex; Active: Boolean);
begin
  if ThreadIndex < 0 then Exit;
  if FThreadList[ThreadIndex].Breakpoint.Active[Index] = Active then Exit;
  FThreadList[ThreadIndex].Breakpoint.Active[Index] := Active;
  UpdateHardwareBreakpoints(ThreadIndex);
end;

Просто меняем флаг Active и опять же вызываем UpdateHardwareBreakpoints.

Ну и удаление:

procedure TFWDebugerCore.DropHardwareBreakpoint(ThreadIndex: Integer;
  Index: THWBPIndex);
begin
  if ThreadIndex < 0 then Exit;
  if FThreadList[ThreadIndex].Breakpoint.Address[Index] = nil then Exit;
  FThreadList[ThreadIndex].Breakpoint.Address[Index] := nil;
  UpdateHardwareBreakpoints(ThreadIndex);
end;

Обнуляем адрес НВР и опять вызываем UpdateHardwareBreakpoints.

Весь нюанс кроется именно в методе UpdateHardwareBreakpoints.
Основная его задача заполнить регистры Dr0-Dr3 адресами активных НВР и провести правильную инициализацию регистра Dr7.

Вот с ним-то придется повозится.

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

Самые старшие 4 бита (31-28) хранят настройки регистра Dr3.
Выглядит это следующим образом:

Старшие 2 бита (LENi) из четырех отвечают за размер контролируемой НВР памяти.
00 - 1 байт
01 - 2 байта
10 - такая комбинация битов не используется.
11 - 4 байта

Младшие 2 бита (RWi) из 4 отвечают за настройку режима работы НВР
00 - Execute
01 - Write
10 - IO Read/Write
11 - Read/Write

Таким образом, если мы хотим чтобы НВР из регистра Dr3 реагировала на запись в любые 4 байта начиная со значения, указанного в Dr3, старшие 4 бита регистра Dr7 должны выглядеть как 1101.

Следующие 4 бита (27-24) используются для настройки НВР регистра Dr2
Биты 23-20 относятся к Dr1 и, в завершение, биты 19-16 к регистру Dr0.

Бит 13 регистра Dr7 (GD - Global Debug Register Access Detect) - отвечает за целостность данных в отладочных регистрах. Например если отлаживаемой программе вдруг вздумалось хранить в этих регистрах свои значения, отладчик будет уведомлен об этом.

Бит 9 регистра Dr7 (GE - Global Exact data breakpoint match) - включает работу с глобальными НВР.
Бит 8 регистра Dr7 (LE - Local Exact data breakpoint match) - включает работу с локальными НВР.

LE бит сбрасывается при переключении задач, более подробнее можно узнать в интеловских мануалах.

Остались 8 бит (7-0) представленные в виде пары флагов Gi и Li для каждого из регистров включающих HBP в глобальном или локальном режиме.

Бит 7 (Gi - Global breakpoint enable) - включает глобальный режим регистру Dr3
Бит 6 (Li - Local breakpoint enable) - включает локальный режим регистру Dr3
5- 4 то же для Dr2
3- 2 для Dr1 и 1-0 для Dr0

Запутались?

Ну тогда вот картинка:



В виде исходного кода все выглядит достаточно просто.

procedure TFWDebugerCore.UpdateHardwareBreakpoints(ThreadIndex: Integer);
const
  DR7_SET_LOC_DR0 = $01;
  DR7_SET_GLB_DR0 = $02;
  DR7_SET_LOC_DR1 = $04;
  DR7_SET_GLB_DR1 = $08;
  DR7_SET_LOC_DR2 = $10;
  DR7_SET_GLB_DR2 = $20;
  DR7_SET_LOC_DR3 = $40;
  DR7_SET_GLB_DR3 = $80;

  DR7_SET_LOC_ON  = $100;
  DR7_SET_GLB_ON  = $200;

  DR7_PROTECT     = $2000;

  DR_SIZE_BYTE    = 0;
  DR_SIZE_WORD    = 1;
  DR_SIZE_DWORD   = 3;

  DR_MODE_E       = 0;
  DR_MODE_W       = 1;
  DR_MODE_I       = 2;
  DR_MODE_R       = 3;

  DR7_MODE_DR0_E  = DR_MODE_E shl 16;
  DR7_MODE_DR0_W  = DR_MODE_W shl 16;
  DR7_MODE_DR0_I  = DR_MODE_I shl 16;
  DR7_MODE_DR0_R  = DR_MODE_R shl 16;

  DR7_SIZE_DR0_B  = DR_SIZE_BYTE shl 18;
  DR7_SIZE_DR0_W  = DR_SIZE_WORD shl 18;
  DR7_SIZE_DR0_D  = DR_SIZE_DWORD shl 18;

  DR7_MODE_DR1_E  = DR_MODE_E shl 20;
  DR7_MODE_DR1_W  = DR_MODE_W shl 20;
  DR7_MODE_DR1_I  = DR_MODE_I shl 20;
  DR7_MODE_DR1_R  = DR_MODE_R shl 20;

  DR7_SIZE_DR1_B  = DR_SIZE_BYTE shl 22;
  DR7_SIZE_DR1_W  = DR_SIZE_WORD shl 22;
  DR7_SIZE_DR1_D  = DR_SIZE_DWORD shl 22;

  DR7_MODE_DR2_E  = DR_MODE_E shl 24;
  DR7_MODE_DR2_W  = DR_MODE_W shl 24;
  DR7_MODE_DR2_I  = DR_MODE_I shl 24;
  DR7_MODE_DR2_R  = DR_MODE_R shl 24;

  DR7_SIZE_DR2_B  = DR_SIZE_BYTE shl 26;
  DR7_SIZE_DR2_W  = DR_SIZE_WORD shl 26;
  DR7_SIZE_DR2_D  = DR_SIZE_DWORD shl 26;

  DR7_MODE_DR3_E  = DR_MODE_E shl 28;
  DR7_MODE_DR3_W  = DR_MODE_W shl 28;
  DR7_MODE_DR3_I  = DR_MODE_I shl 28;
  DR7_MODE_DR3_R  = DR_MODE_R shl 28;

  DR7_SIZE_DR3_B  = DR_SIZE_BYTE shl 30;
  DR7_SIZE_DR3_W  = DR_SIZE_WORD shl 30;
  DR7_SIZE_DR3_D  = $C0000000; //DR_SIZE_DWORD shl 30;

  DR_On: array [THWBPIndex] of DWORD = (
    DR7_SET_LOC_DR0,
    DR7_SET_LOC_DR1,
    DR7_SET_LOC_DR2,
    DR7_SET_LOC_DR3
  );

  DR_Mode: array [THWBPIndex] of array [THWBPMode] of DWORD = (
    (DR7_MODE_DR0_E, DR7_MODE_DR0_W, DR7_MODE_DR0_I, DR7_MODE_DR0_R),
    (DR7_MODE_DR1_E, DR7_MODE_DR1_W, DR7_MODE_DR1_I, DR7_MODE_DR1_R),
    (DR7_MODE_DR2_E, DR7_MODE_DR2_W, DR7_MODE_DR2_I, DR7_MODE_DR2_R),
    (DR7_MODE_DR3_E, DR7_MODE_DR3_W, DR7_MODE_DR3_I, DR7_MODE_DR3_R)
  );

  DR_Size: array [THWBPIndex] of array [THWBPSize] of DWORD = (
    (DR7_SIZE_DR0_B, DR7_SIZE_DR0_W, DR7_SIZE_DR0_D),
    (DR7_SIZE_DR1_B, DR7_SIZE_DR1_W, DR7_SIZE_DR1_D),
    (DR7_SIZE_DR2_B, DR7_SIZE_DR2_W, DR7_SIZE_DR2_D),
    (DR7_SIZE_DR3_B, DR7_SIZE_DR3_W, DR7_SIZE_DR3_D)
  );

var
  Context: TContext;
  I: THWBPIndex;
begin
  if ThreadIndex < 0 then Exit;

  ZeroMemory(@Context, SizeOf(TContext));
  Context.ContextFlags := CONTEXT_DEBUG_REGISTERS;

  for I := 0 to 3 do
  begin
    if not FThreadList[ThreadIndex].Breakpoint.Active[I] then Continue;
    if FThreadList[ThreadIndex].Breakpoint.Address[I] <> nil then
    begin
      Context.Dr7 := Context.Dr7 or DR7_SET_LOC_ON;
      case I of
        0: Context.Dr0 := DWORD(FThreadList[ThreadIndex].Breakpoint.Address[I]);
        1: Context.Dr1 := DWORD(FThreadList[ThreadIndex].Breakpoint.Address[I]);
        2: Context.Dr2 := DWORD(FThreadList[ThreadIndex].Breakpoint.Address[I]);
        3: Context.Dr3 := DWORD(FThreadList[ThreadIndex].Breakpoint.Address[I]);
      end;
      Context.Dr7 := Context.Dr7 or DR_On[I];
      Context.Dr7 := Context.Dr7 or DR_Mode[I,
        FThreadList[ThreadIndex].Breakpoint.Mode[I]];
      Context.Dr7 := Context.Dr7 or DR_Size[I,
        FThreadList[ThreadIndex].Breakpoint.Size[I]];
    end;
  end;

  Check(SetThreadContext(FThreadList[ThreadIndex].ThreadHandle, Context));
end;

Если не обращать внимание на блок констант, предшествующий коду, то инициализация регистра Dr7 реализована всего лишь тремя строчками.

      Context.Dr7 := Context.Dr7 or DR_On[I];
      Context.Dr7 := Context.Dr7 or DR_Mode[I,
        FThreadList[ThreadIndex].Breakpoint.Mode[I]];
      Context.Dr7 := Context.Dr7 or DR_Size[I,
        FThreadList[ThreadIndex].Breakpoint.Size[I]];

Ну не считая включения бита LE представленного константой DR7_SET_LOC_ON.

Теперь перейдем к обработке НВР.

При срабатывании ВР мы получали код EXCEPTION_BREAKPOINT.
При срабатывании МВР код был EXCEPTION_GUARD_PAGE.
А при прерывании на НВР  нам будет сгенерировано событие EXCEPTION_DEBUG_EVENT с кодом EXCEPTION_SINGLE_STEP, которое помимо прочего используется для восстановления состояния ВР и МВР (поэтому я и не стал приводить его реализацию в начале статьи).

При получении EXCEPTION_SINGLE_STEP самым первым вызывается обработчик НВР реализованный следующим образом:

function TFWDebugerCore.ProcessHardwareBreakpoint(ThreadIndex: Integer;
  DebugEvent: TDebugEvent): Boolean;
var
  Index: Integer;
  Context: TContext;
  ReleaseBP: Boolean;
begin
  ZeroMemory(@Context, SizeOf(TContext));
  Context.ContextFlags := CONTEXT_DEBUG_REGISTERS;
  Check(GetThreadContext(FThreadList[ThreadIndex].ThreadHandle, Context));
  Result := Context.Dr6 and $F <> 0;
  if not Result then Exit;

  Index := -1;
  if Context.Dr6 and 1 <> 0 then
    Index := 0;
  if Context.Dr6 and 2 <> 0 then
    Index := 1;
  if Context.Dr6 and 4 <> 0 then
    Index := 2;
  if Context.Dr6 and 8 <> 0 then
    Index := 3;
  if Index < 0 then
  begin
    Result := False;
    Exit;
  end;

  ReleaseBP := False;

  if Assigned(FHardwareBreakpoint) then
    FHardwareBreakpoint(Self, ThreadIndex, DebugEvent.Exception.ExceptionRecord,
      Index, ReleaseBP);

  ToggleHardwareBreakpoint(ThreadIndex, Index, False);
  SetSingleStepMode(ThreadIndex, False);

  if ReleaseBP then
    DropHardwareBreakpoint(ThreadIndex, Index)
  else
  begin

    // если два HWBP идут друг за другом,
    // то т.к. восстановление происходит через индексы
    // в ProcessExceptionSingleStep, индекс предыдущего HWBP будет претерт
    // поэтому перед перетиранием индексов нужно восстановить предыдущий HWBP
    if (FRestoredThread >= 0) and (FRestoredHWBPIndex >= 0) then
      ToggleHardwareBreakpoint(FRestoredThread, FRestoredHWBPIndex, True);

    FRestoredHWBPIndex := Index;
    FRestoredThread := ThreadIndex;
  end;
end;

Его задача определить какой именно НВР вызвал прерывание, вызвать внешнее событие и выполнить алгоритм финализации примерно похожий на уже показанные в обработчиках ВР и МВР.

Для определения номера НВР необходимо считать значение регистра Dr6 из контекста нити.
Младшие 4 бита данного регистра представляют из себя флаги принимающие значение 1 в том случае, если сработал соответствующий им DrX регистр.

Все достаточно просто, после определения необходимого НВР вызываем внешнее событие, отключаем НВР, переводим процессор в режим трассировки (без правки EIP) после чего либо удаляем НВР, либо запоминаем его индекс в двух переменных, ориентируясь на которые обработчик EXCEPTION_SINGLE_STEP восстановит состояние НВР.

Ну что ж, вот кажется мы и подошли к логическому завершению.
Осталось только показать реализацию самого обработчика EXCEPTION_SINGLE_STEP.

Выглядит он следующим образом:

procedure TFWDebugerCore.ProcessExceptionSingleStep(ThreadIndex: Integer;
  DebugEvent: TDebugEvent);
var
  Handled: Boolean;
begin
  // Обрабатываем HWBP
  Handled := ProcessHardwareBreakpoint(ThreadIndex, DebugEvent);

  // Если событие поднято из-за HWPB восстанавливаем предыдущий HWBP
  if not Handled and (FRestoredThread >= 0) and
    (FRestoredHWBPIndex >= 0) then
  begin
    ToggleHardwareBreakpoint(FRestoredThread, FRestoredHWBPIndex, True);
    FRestoredThread := -1;
    FRestoredHWBPIndex := -1;
  end;

  // Восстанавливаем ВР
  if FRestoreBPIndex >= 0 then
  begin
    CheckBreakpointIndex(FRestoreBPIndex);
    if FBreakpointList[FRestoreBPIndex].bpType = btBreakpoint then
      ToggleInt3Breakpoint(FRestoreBPIndex, True);
    FRestoreBPIndex := -1;
  end;

  // Восстанавливаем MВР
  if FRestoreMBPIndex >= 0 then
  begin
    CheckBreakpointIndex(FRestoreMBPIndex);
    if FBreakpointList[FRestoreMBPIndex].bpType = btMemoryBreakpoint then
      ToggleMemoryBreakpoint(FRestoreMBPIndex, True);
    FRestoreMBPIndex := -1;
  end;

  // если на предыдущий итерации был выставлен режим трассировки
  // уведомляем о нем пользователя
  if ResumeAction <> raRun then
  begin
    CallUnhandledExceptionEvents(ThreadIndex, ecSingleStep, DebugEvent);

    // после чего настраиваем отладчик в зависимости от команды пользователя
    DoResumeAction(ThreadIndex);
  end;
end;

В его задачу входит первоначально определить, произошла ли генерация исключения по причине остановки на НВР. Если это действительно так, то вызовом ToggleHardwareBreakpoint НВР возвращается на место.
Если же исключение было поднято по причине включенного флага трассировки после обработки ВР или МВР, переменные FRestoreBPIndex и FRestoreMBPIndex будут указывать на индекс точки остановки, которую требуется вернуть на место.
В зависимости от ее типа производятся вызовы методов ToggleInt3Breakpoint или ToggleMemoryBreakpoint.

Практика:


На этом с описанием реализации отладчика я закончу, но не торопитесь - осталось еще несколько моментов, которые хотелось бы показать на практике.
Ну как в том анекдоте про самолет: "а сейчас мы попробуем всю эту байду поднять в воздух" :)

Для этого нам потребуется реализовать два приложения.

Первое - его мы будем пытаться отлаживать. Создайте новый VCL проект, сохраните его с именем "test_app", после чего скомпилируйте проект.

Теперь напишем приложение - отладчик. Для этого нам будет достаточно формы, две кнопки ( для запуска и остановки процесса отладки) и TMemo или TRichEdit, куда будет выводится вся информация.

Пишем:

type
  TdlgDebuger = class(TForm)
    Panel1: TPanel;
    btnStart: TButton;
    btnStop: TButton;
    edLog: TRichEdit;
    procedure btnStartClick(Sender: TObject);
    procedure btnStopClick(Sender: TObject);
  private
    FCore: TFWDebugerCore;
    FNeedStop: Boolean;
    procedure Writeln(const Value: string = '');
  end;

...

procedure TdlgDebuger.btnStartClick(Sender: TObject);
var
  Path: string;
begin
  FNeedStop := False;
  // путь к отлаживаемому приложению измените на свой
  Path := ExtractFilePath(ParamStr(0)) + '..\test_app\test_app.exe';
  FCore := TFWDebugerCore.Create(50);
  try
    btnStart.Enabled := False;
    btnStop.Enabled := True;
    if not FCore.DebugNewProcess(Path, True) then
      RaiseLastOSError;
    FCore.RunMainLoop;
  finally
    FCore.Free;
    btnStart.Enabled := True;
    btnStop.Enabled := False;
  end;
  Writeln;
  Writeln('Debug stop');
end;

procedure TdlgDebuger.Writeln(const Value: string);
begin
  edLog.Lines.Add(Value);
end;

procedure TdlgDebuger.btnStopClick(Sender: TObject);
begin
  FNeedStop := True;
end;

Должно получится что-то вроде такого:


Нажмите кнопку "Start", если все сделано правильно запустится тестовое приложение.

При этом основное приложение перестанет практически реагировать на команды мышки и клавиатуры.

Дело в том, что после запуска оно находится внутри отладочного цикла TFWDebugerCore.RunMainLoop, который не дает выполнится циклу выборки очереди сообщений.

Закройте тестовое приложение, это позволит отладчику выйти из отладочного цикла и дать возможность работы с окном.

По хорошему, отладчик надо запускать в отдельной нити (точнее не по хорошему - а это именно правильный подход) но даже без ее запуска мы можем с ним работать нормальным способом перекрыв событие OnIdle класса TFWDebugerCore, в котором напишем следующий код:

procedure TdlgDebuger.OnIdle(Sender: TObject);
begin
  if FNeedStop then
    FCore.StopDebug
  else
    Application.ProcessMessages;
end;

Вызов Application.ProcessMessages не даст нашему приложению тормозить в процессе работы.

Теперь попробуем получить отладочную информацию о процессе примерно в таком виде, в каком ее выводит отладчик Delphi. Для этого подключим обработчики OnCreateProcess и OnLoadDll.

В первом напишем следующее:

procedure TdlgDebuger.OnCreateProcess(Sender: TObject;
  ThreadIndex: Integer; Data: TCreateProcessDebugInfo);
var
  T: TThreadData;
begin
  T := FCore.GetThreadData(ThreadIndex);
  Writeln(Format('CreateThread ID: %d', [T.ThreadID]));
  Writeln(Format('ProcessStart ID: %d', [FCore.DebugProcessData.ProcessID]));
end;

Во втором такой код:

procedure TdlgDebuger.OnLoadDll(Sender: TObject; ThreadIndex: Integer;
  Data: TLoadDLLDebugInfo);
const
  FormatStrKnownDLL = 'Load Dll at instance %p handle %d "%s"';
  FormatStrUnknownDLL = 'Load unknown Dll at instance %p handle %d';
var
  DllName: AnsiString;
  IsUnicodeData: Boolean;
begin
  FCore.ContinueStatus := DBG_EXCEPTION_NOT_HANDLED;
  IsUnicodeData := Data.fUnicode = 1;
  DllName := FCore.GetDllName(Data.lpImageName, Data.lpBaseOfDll, IsUnicodeData);
  if DllName <> '' then
  begin
    if IsUnicodeData then
      Writeln(Format(FormatStrKnownDLL, [Data.lpBaseOfDll,
        Data.hFile, PWideChar(@DllName[1])]))
    else
      Writeln(Format(FormatStrKnownDLL, [Data.lpBaseOfDll,
        Data.hFile, PAnsiChar(@DllName[1])]));
  end
  else
    Writeln(Format(FormatStrUnknownDLL, [Data.lpBaseOfDll, Data.hFile]));
end;

После чего опять запустим отладочное приложение и нажмем кнопку "Start"

Должно получится следующее:


Ну вроде - похоже.

Реализация трассировки:


Теперь попробуем поработать с отладчиком. Для начала рассмотрим два варианта трассировки, первый через TF флаг, второй через MBP. Трассировать будем первые 40 байт с точки входа программы. Давайте сразу посмотрим как они выглядят:



Для того чтобы начать трассировку по хорошему нужно дождаться инициализации процесса, после чего можно смело ставить ВР/МВР и прочее. Для этого необходимо указать отладчику установить ВР на точку входа приложения. Отвечает за это второй параметр функции DebugNewProcess. Он у нас уже выставлен в True, и осталось только обработать данный ВР. Для этого подключим обработчик OnBreakPoint в котором выставим режим трассировки.

procedure TdlgDebuger.OnBreakPoint(Sender: TObject; ThreadIndex: Integer;
  ExceptionRecord: Windows.TExceptionRecord; BreakPointIndex: Integer;
  var ReleaseBreakpoint: Boolean);
begin
  // Выводим пойманный ВР в лог
  Writeln(Format('!!! --> Breakpoint "%s"',
    [FCore.BreakpointItem(BreakPointIndex).Description]));
  // Снимаем его (больше он нам не потребуется)
  ReleaseBreakpoint := True;
  // Включаем режим трассировки
  FCore.ResumeAction := raTraceInto;
  // Инициализируем количество трассировочных прерываний
  FStepCount := 0;
end;

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

procedure TdlgDebuger.OnSingleStep(Sender: TObject; ThreadIndex: Integer;
  ExceptionRecord: Windows.TExceptionRecord);
begin
  // Выводим пойманный ВР в лог
  Inc(FStepCount);
  Writeln(Format('!!! --> trace step №%d at addr 0x%p',
    [FStepCount, ExceptionRecord.ExceptionAddress]));
  // В зависимости от количества срабатываний отключаем трассировку
  if FStepCount > 10 then
    FCore.ResumeAction := raRun
  else
    FCore.ResumeAction := raTraceInto;
end;

Результатом получится следующий вывод:


Мы сделали трассировку StepIn, об этом нам явно показывает пятый шаг трассировки, который произошел по адресу 0x00409FF4, это начало функции _InitExe(), вызов которой происходит по адресу 0x00409C53. Трассировка достаточно медленный процесс и ждать, когда управление вернется из функции _InitExe(), я не стал, для демонстрации ограничившись десятком шагов.

Второй режим трассировки - установка МВР.
Для того чтобы ее продемонстрировать, необходимо перекрыть событие OnPageGuard и при достижении точки входа, вызвать метод SetMemoryBreakpoint с диапазоном контролируемой памяти равным нулю. В этом случае отладчик будет знать о контролируемой МВР странице, но обработчик OnBreakPoint для данной МВР вызываться не будет. Реализацию данной варианта трассировки оставляю на ваше усмотрение, единственно дам подсказку, из обработчиков отладочных событий крайне не рекомендуется вызывать метод RemoveBreakpoint (уплывут индексы), для удаления ВР/МВР/НВР в данной реализации отладчика предусмотрено два штатных метода, параметр ReleaseBreakpoint при вызове обработчика ВР, или процедура RemoveCurrentBreakpoint доступная в любом из обработчиков. Вероятно в следующих реализациях класса TFWDebugerCore данное поведение будет пересмотрено, но для статьи пойдет пока что и такой вариант.

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

Получение отладочной строки:


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

//
//  Вывод отладочной строки
// =============================================================================
procedure TForm1.btnDebugStringClick(Sender: TObject);
begin
  OutputDebugString('Test debug string');
end;

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

procedure TdlgDebuger.OnDebugString(Sender: TObject; ThreadIndex: Integer;
  Data: TOutputDebugStringInfo);
begin
  if Data.fUnicode = 1 then
    Writeln('DebugString: ' + 
      PWideChar(FCore.ReadStringW(Data.lpDebugStringData, Data.nDebugStringLength)))
  else
    Writeln('DebugString: ' + 
      PAnsiChar(FCore.ReadStringA(Data.lpDebugStringData, Data.nDebugStringLength)));
end;

Запускайте отладчик, в нем отлаживаемое приложение и пощелкайте на кнопке. Сообщение "Test debug string" выводится в лог? Если да, то значит все сделали правильно :)

Обработка исключений:


Помните я говорил о использовании ВР в качестве антиотладочного в приложении? Сейчас попробуем рассмотреть примерно такой-же вариант. В тестовом приложении добавьте еще одну кнопку и в ней пропишите следующий код:

//
//  Детектируем отладчик через поднятие отладочного прерывания
// =============================================================================
procedure TForm1.btnExceptClick(Sender: TObject);
begin
  try
    asm
      int 3
    end;
    ShowMessage('Debugger detected.');
  except
    ShowMessage('Debugger not found.');
  end;
end;

Это в принципе даже не антиотладка, но как ни странно иногда некоторые реверсеры палятся даже на такой примитивной схеме.

Суть данного метода в следующем: в начале статьи я приводил пример отладочного цикла, в нем на каждой итерации параметр ContinueStatus, с которым вызывается функция ContinueDebugEvent, инициализировался константой DBG_CONTINUE. Что это означает? Это сигнал что наш отладчик успешно обработал возникшее исключение и дальше с ним возится не стоит.

Ну а теперь что это означает на примере приведенного кода: вызовом инструкции "INT3" мы поднимаем исключение. При нормальной работе приложения данное исключение обрабатывать некому, поэтому при его возникновении происходит переход на обработчик exception..end, в котором мы говорим что все нормально. Если же мы под отладчиком, то это исключение поймает он и вызова обработчика приложения не произойдет.

Можете проверить, запустить приложение и нажмите кнопку с этим кодом, оно честно скажет - мы под отладчиком.

Побороть данный код так же достаточно просто, достаточно перекрыть событие OnUnknownBreakPoint (int3 - это точка остановки, причем поставленная не нами, поэтому и ловить ее будет в данном событии). В обработчике события напишите следующий код:

procedure TdlgDebuger.OnUnknownBreakPoint(Sender: TObject;
  ThreadIndex: Integer; ExceptionRecord: Windows.TExceptionRecord);
var
  ApplicationBP: Boolean;
begin
  ApplicationBP :=
    (DWORD(ExceptionRecord.ExceptionAddress) > FCore.DebugProcessData.EntryPoint) and
    (DWORD(ExceptionRecord.ExceptionAddress) < $500000);

  Writeln;
  if ApplicationBP then
  begin
    Writeln(Format('!!! --> Unknown application breakpoint at addr 0X%p',
      [ExceptionRecord.ExceptionAddress]));
    Writeln('!!! --> Exception not handled.');
    FCore.ContinueStatus := DBG_EXCEPTION_NOT_HANDLED;
  end
  else
  begin
    Writeln(Format('!!! --> Unknown breakpoint at addr 0X%p',
      [ExceptionRecord.ExceptionAddress]));
    Writeln('!!! --> Exception handled.');
    FCore.ContinueStatus := DBG_CONTINUE;
  end;
  Writeln;
end;

В нем все просто, на основании адреса по которому установлен ВР определяем его расположение, в теле приложения или нет (грубо взяв диапазон от адреса загрузки приложения до $500000). Если ВР установлено в теле приложения - значит это какая-то антиотладка. Говорим отладчику, мы знать не знаем что с ней делать, выставив флаг DBG_EXCEPTION_NOT_HANDLED, в противном случае просто выводим в лог информацию  о том, что кто-то еще играется с точками остановки.

В результате таких телодвижений исключение, искусственно поднятое приложением, обработано не будет и оно радостно сообщит нам, что отладчика не обнаружило :)

Что происходит при переполнении стека:


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

//
//  Разрушаем стек приложения через переполнение
// =============================================================================
procedure TForm1.btnKillStackClick(Sender: TObject);
 
  procedure T;
  var
    HugeBuff: array [0..10000] of DWORD;
  begin
    if HugeBuff[0] <> HugeBuff[10000] then
      Inc(HugeBuff[0]);
    T;
  end;
 
begin
  try
    T;
  except
    T;
  end;
end;

Добавьте этот код в тестовое приложения и нажмите кнопку. Реакция отладчика может быть разная, но результат всегда будет один - отладчику станет очень плохо. Что происходит в данном случае? Механизм детектирования переполнения стека достаточно прост, граница, за которую нельзя выходить представлена отдельной страницей, помеченой флагом PAGE_GUARD. Ну да, это тот-же механизм, при помощи которого мы расставляли наши МВР, но в данном случае он используется для других целей. При переполнении первоначально отладчику придет уведомление EXCEPTION_STACK_OVERFLOW. В принципе уже прямо здесь можно "сушить весла" и завершать работу отладчика, но мы же настырные и запустим работу тестового приложения дальше. Если помните, нюанс работы флага PAGE_GUARD в том, что после первого обращения он снимается, вот здесь такой-же случай. При повторном обращении к данной странице мы поймаем не что иное, как EXCEPTION_ACCESS_VIOLATION и вот тут-то уже действительно "все", дальше барахтаться уже смысла не имеет, остается только выставить DBG_CONTROL_C и прекратить отладку (ну если вы конечно не хотите понаблюдать на вечный цикл с выдачей AV).

Обработчик переполнения реализуем в событии OnUnknownException, т.к. TFWDebugerCore не выносит эти два исключения в виде отдельных событий. В нем напишем следующее:

procedure TdlgDebuger.OnUnknownException(Sender: TObject;
  ThreadIndex: Integer; ExceptionRecord: Windows.TExceptionRecord);
var
  Cause: string;
begin
  Writeln;

  case ExceptionRecord.ExceptionCode of

    EXCEPTION_STACK_OVERFLOW:
    begin
      Writeln('!!! --> Stack overflow detected. Probe to continue.');
      FCore.ContinueStatus := DBG_CONTINUE;
    end;

    EXCEPTION_ACCESS_VIOLATION:
    begin
      {
        The first element of the array contains a read-write flag that
        indicates the type of operation that caused the access violation.
        If this value is zero, the thread attempted to read the inaccessible data.
        If this value is 1, the thread attempted to write to an inaccessible address.
        If this value is 8, the thread causes a user-mode data execution prevention (DEP) violation.
        The second array element specifies the virtual address of the inaccessible data.
      }
      case ExceptionRecord.ExceptionInformation[0] of
        0: Cause := 'read';
        1: Cause := 'write';
        8: Cause := 'DEP violation';
      else
        Cause := 'unknown cause';
      end;

      Writeln(Format('!!! --> Access violation at addr 0x%p %s of address 0x%p',
        [
          ExceptionRecord.ExceptionAddress,
          Cause,
          Pointer(PDWORD(@ExceptionRecord.ExceptionInformation[1])^)
        ]));
      Writeln('!!! --> Process Stopped.');
      FCore.ContinueStatus := DBG_CONTROL_C;
    end;

  else

    Writeln(Format('!!! --> Unknown exception code %p at addr 0x%p',
      [
        Pointer(ExceptionRecord.ExceptionCode),
        ExceptionRecord.ExceptionAddress
      ]));

  end;

  Writeln;
end;

Получится должно вот так:


Резюмируя:


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

Исходный код к статье можно скачать по данной ссылке: http://rouse.drkb.ru/blog/dbg_part2.zip

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

И на этом у меня действительно все.

---

© Александр (Rouse_) Багель
Москва, ноябрь 2012

13 комментариев:

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

    Кстати, у тебя много наработок было до публикации? Удивляюсь объёмному материалу в короткие сроки :)

    ОтветитьУдалить
    Ответы
    1. Эмм, тут немного не про те привилегии, если отлаживаемый запущен из под админской учетки (UAC - run as Administrator), то мы к нему даже с отладочными привилегиями не присодинимся.
      А вот на XP, к примеру без SeDebug к csrss доступа не будет даже на чтение (так кстати иногда и определяют - под отладкой мы или нет).

      По наработкам...
      Наработок законченных не было - все с нуля писалось. Ну точнее как, по работе я делал порядка семи реализаций отладчика, но заточенных под конкретную задачу. А щас решил все обобщить и сделать что-то универсальное, ну и ребята с IT отдела подкинули идею, мол хватит нам рассказывать интересные вещи в курилке, давай-ка ты всю свою практику по защите ПО на блог выложи и нам только ссылки давать будешь :))

      Удалить
    2. Хорошую идею ребята подкинули :) Оба поста про отладчик однозначно в закладки. Спасибо, Александр!

      Удалить
  2. Огромное спасибо за статью!

    ОтветитьУдалить
  3. Замечательная статья! Спасибо за проделанную работу!

    ОтветитьУдалить
  4. Мне очень понравился Ваш стиль. А статья - в избранных. С большим нетерпением жду третью часть. Спасибо Вам!

    ОтветитьУдалить
  5. OllyDbg 2.01 правильно распознает длинный NOP.

    ОтветитьУдалить
    Ответы
    1. Двойку я не тестировал (банально забыл про нее при подготовке статьи), а первая гарантированно ошибается: http://rouse.drkb.ru/tmp/olly.png

      Удалить
    2. http://ipicture.ru/Gallery/View/22364554.html
      http://s2.ipicture.ru/Gallery/Viewfull/22364554.html

      Удалить
    3. Ага, спасибо, вижу.
      Она как вообще - юзабельная? Что-то руки все никак не доходят поработать с ней, да и слышал что сыровата маленько, или уже все не так плохо?

      Удалить
    4. Сыровата конечно, но пользоваться можно.

      Удалить
  6. в процедуру procedure ProcessExceptionBreakPoint
    необходимо добавить проверку:

    между строками
    CallUnhandledExceptionEvents(ThreadIndex, ecBreakpoint, DebugEvent);
    и
    ToggleInt3Breakpoint(BreakPointIndex, False);

    нужно написать
    if FProcessInfo.ProcessID = 0 then Exit;
    иначе, при вызове из кода
    FCore.StopDebug получим ошибку в функции ToggleInt3Breakpoint

    ОтветитьУдалить
    Ответы
    1. Ну в принципе да, правда я изначально закладывался на остановку отладки только в IDLE обработчике. В принципе поле для причесывания достаточно большое, взять ту-же невозможность снять любой BP из любого обработчика по причине изменения индексов и т.п.
      В частности и описанный случай с остановкой отладки.

      Удалить