В первой части статьи были рассмотрены некоторые нюансы работы с интегрированным отладчиком 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)? Вот сейчас мы это и рассмотрим более подробно.
Большинство исключений в процессе отладки являются наведенными. То есть получение исключения не означает что в самой программе произошла ошибка. Скорее всего исключение произошло из-за вмешательства отладчика в работу приложения, например посредством установки ВР.
Нюанс: Если в самом приложении происходит ошибка, мы ее так-же получим в виде отладочного исключения и нам нужно будет реализовать код отладчика таким образом, чтобы мы могли отличать наши наведенные ошибки, от пользовательских.
Обычно отладчик предоставляет три механизма работы с ВР (ну если не учитывать ВР на загрузку модуля, т.к. фактически эта возможность не является классическим ВР).
- Стандартный ВР на строчку кода.
- ВР на адрес памяти (Memory Breakpoint или урезанный Data Preakpoint в Delphi).
- 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
"Перед подключением к процессу самым первым действием необходимо получить отладочные привилегии" - вообще-то нет. Любой процесс может быть отладчиком, для этого не нужны никакие особые привелегии. Debug-привилегия нужна как раз для "необходимый нам процесс запущен с более высокими привилегиями, чем у отладчика".
ОтветитьУдалитьКстати, у тебя много наработок было до публикации? Удивляюсь объёмному материалу в короткие сроки :)
Эмм, тут немного не про те привилегии, если отлаживаемый запущен из под админской учетки (UAC - run as Administrator), то мы к нему даже с отладочными привилегиями не присодинимся.
УдалитьА вот на XP, к примеру без SeDebug к csrss доступа не будет даже на чтение (так кстати иногда и определяют - под отладкой мы или нет).
По наработкам...
Наработок законченных не было - все с нуля писалось. Ну точнее как, по работе я делал порядка семи реализаций отладчика, но заточенных под конкретную задачу. А щас решил все обобщить и сделать что-то универсальное, ну и ребята с IT отдела подкинули идею, мол хватит нам рассказывать интересные вещи в курилке, давай-ка ты всю свою практику по защите ПО на блог выложи и нам только ссылки давать будешь :))
Хорошую идею ребята подкинули :) Оба поста про отладчик однозначно в закладки. Спасибо, Александр!
УдалитьОгромное спасибо за статью!
ОтветитьУдалитьЗамечательная статья! Спасибо за проделанную работу!
ОтветитьУдалитьМне очень понравился Ваш стиль. А статья - в избранных. С большим нетерпением жду третью часть. Спасибо Вам!
ОтветитьУдалитьOllyDbg 2.01 правильно распознает длинный NOP.
ОтветитьУдалитьДвойку я не тестировал (банально забыл про нее при подготовке статьи), а первая гарантированно ошибается: http://rouse.drkb.ru/tmp/olly.png
Удалитьhttp://ipicture.ru/Gallery/View/22364554.html
Удалитьhttp://s2.ipicture.ru/Gallery/Viewfull/22364554.html
Ага, спасибо, вижу.
УдалитьОна как вообще - юзабельная? Что-то руки все никак не доходят поработать с ней, да и слышал что сыровата маленько, или уже все не так плохо?
Сыровата конечно, но пользоваться можно.
Удалитьв процедуру procedure ProcessExceptionBreakPoint
ОтветитьУдалитьнеобходимо добавить проверку:
между строками
CallUnhandledExceptionEvents(ThreadIndex, ecBreakpoint, DebugEvent);
и
ToggleInt3Breakpoint(BreakPointIndex, False);
нужно написать
if FProcessInfo.ProcessID = 0 then Exit;
иначе, при вызове из кода
FCore.StopDebug получим ошибку в функции ToggleInt3Breakpoint
Ну в принципе да, правда я изначально закладывался на остановку отладки только в IDLE обработчике. В принципе поле для причесывания достаточно большое, взять ту-же невозможность снять любой BP из любого обработчика по причине изменения индексов и т.п.
УдалитьВ частности и описанный случай с остановкой отладки.