На главную

Строим отладчик

Разбираемся с механизмами отладки и пишем собственный дебаггер

Думаю, ты не раз сталкивался с отладчиком: чужую прогу взломать, свою отладить - тут без него никуда. Дебаггер - незаменимая для хакера или кодера вещь. Но задумывался ли ты, как он работает? Как отлаживает? Если да, то тебе очень пригодится этот материал. В нем я постараюсь объяснить природу работы отладчиков, механизмы, с помощью которых на самом деле не очень сложная программа получает полную власть над любым процессом в системе. Причем не только объяснить, но и даже показать, как разработать несложный обезжучиватель самостоятельно!

[что такое отладчик]

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

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

[старинные способы отладки]

Для начала определим два важных действия, которые должен выполнять отладчик и чаще всего выполняет. Без них фактически невозможно вмешиваться в выполнение программы. Несомненно, такими действиями будут трассировка и установка брекпоинтов. Раз уж я взялся за экскурс в историю, вспомню замечательный компьютер ZX-Spectrum. Для тех, кто в танке, - это машина с примитивным процессором и 32 килобайтами (в базовой конфигурации) памяти. Ни о каком защищенном режиме и многозадачности в ней нет и речи, но даже на этом компьютере были отладчики, позволяющие трассировать программу и ставить брекпоинты.

Установка «бряков» заключалась в перезаписи 3 байт кода на инструкцию jmp указывающую на код дебаггера. Естественно, программа доходила до этой точки и передавала управление отладчику. После срабатывания брекпоинт снимался, а код программы возвращается обратно на свое место.

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

[современные способы отладки]

С появлением x86-процессоров все стало гораздо проще. Камни стали поддерживать отладку на аппаратном уровне, а начиная с 386 еще и отладку в защищенном режиме с аппаратными точками останова.

Что же у нас здесь есть для установки брекпоинтов? Самая часто используемая возможность - третье прерывание. Просто один байт отлаживаемой программы заменяется на однобайтовую инструкцию int 3, которая при срабатывании передает управление отладчику. Этот способ несколько напоминает старый трюк с jmp, но в отличие от него позволяет производить отладку в защищенном режиме и размещать отладчик там, куда не доберется отлаживаемая программа, как бы она этого не захотела (в нулевом кольце защиты). Достоинство этого метода в том, что он позволяет ставить неограниченное число брекпоинтов, а недостаток - в непосредственной модификации кода программы. Многие защиты проверяют целостность своего кода, а значит, такой метод на них срабатывать не будет.

Однако современные способы на этом не заканчиваются. Процессоры серии 386+ имеют чрезвычайно удобный и полезный отладочный механизм - аппаратные DR-регистры. Их всего шесть: dr0, dr1, dr2, dr3, dr6 и dr7, причем первые четыре используются для задания точек останова, а последний управляет режимом работы всей этой системы. И что же нам это дает? А дает нам это немало:

1) Останов по чтению/записи/исполнению областей памяти: от 1 до 4-х байт.

2) Останов по инструкциям ввода-вывода (in и out) для определенных портов.

3) Возможность отслеживать попытку изменения содержимого регистров dr0-dr3 (контролируется GD битом в cr4).

Взгляни на структуру отладочных регистров, изображенную на картинке. Как видишь, dr0-dr3 содержат линейный адрес точки останова. Этот адрес (при использовании страничной адресации) может не совпадать с физическим. Единственное, что нам тут надо учесть - это то, что линейный адрес брекпоинтов на исполнение кода берется относительно регистра cs, а для доступа к данным - относительно ds. Поставить «бряк» на области памяти, адресуемые через другие сегменты, можно только тогда, когда они перекрываются с cs, либо ds, и при этом нам надо вычислить виртуальный адрес нужных данных в другом сегменте. Где может понадобиться такой трюк? Скорее всего, для отслеживания установки программой SEH-обработчиков (доступ в сегменте адресуемом через fs).

А теперь давай подробнее рассмотрим управляющие биты в регистрах dr6 и dr7. В dr6:

бит BT - устанавливается, если происходит включение задачи в задачу, где TSS имеет установленный бит ловушки отладки.

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

бит BD - устанавливается аппаратурой, если следующая команда получает доступ к регистру отладки.

биты B0-B3 - устанавливаются, если произошло прерывание ограниченного использования. В0 устанавливается, если произошло прерывание 0 (точка останова) и т.д.

dr7 - уже побольше, ведь он - Регистр Управления Отладкой, и используется для разрешения или уточнения различных точек останова. Состоит регистр из следующих составляющих:

LEN - двухбитовое поле, которое определяет длину прерывания. Все прерывания должны быть выровнены: 2-х байтовые векторы по границам слова; 4-х байтовые по границе двойных слов.

00 - длина в один байт,

01 - длина в два байта,

10 - неопределена,

11 - длина в четыре байта.

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

GE/LE - Глобальная и Локальная Верные Точки Останова: эти биты должны быть всегда установлены на 1 при использовании прерываний.

Gi/Li - Разблокировка Глобальной и Локальной Точек Останова: если Gi=1, либо Li=1, то прерывания разблокируются. Если эти биты установлены, то любое прерывание ограниченного использования (то есть точка останова, которая соответствует условиям, определенным битами LE) заставит процессор выполнять программу обработки отладки. Биты Li позволяют устанавливать локальные точки останова для индивидуальной задачи, но при этом не оказывают влияния на другую задачу. Gi позволяют устанавливать прерывания, воздействующие на все задачи.

Для того чтобы установить точку останова, микропроцессор должен работать на 0 уровне привилегий, в Реальном режиме. Точка останова должна быть установлена путем загрузки регистра точки прерывания (посредством команды MOV DRi,[операнд в памяти или регистре]). После чего должны быть установлены соответствующий LEN и RWE, а также биты разблокировки точки прерывания Gi и/или Li.

Биты Bi в DR6 всегда покажут любую точку останова ограниченного использования; но до тех пор, пока не будут установлены Gi или Li, процессор не будет выполнять программу отладки с этими прерываниями.

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

Трассировка в процессорах x86 реализуется посредством флага трассировки (9-ый бит) в регистре флагов efl. При установке этого бита каждая следующая выполняемая команда будет вызывать исключение int 1. Это обычно и используется отладчиками для трассировки исполнения кода. Установить этот флаг можно с помощью команд влияющих на регистр флагов (iretd, retf, popfd), либо (применительно к Windows) изменив контекст потока с помощью GetThreadContext/SetThreadContext.

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

1) трассируемая команда вызывает исключение

2) трассируемая программа выполняет pushfd (а там TF=1...)

3) трассируемая программа выполняет popfd, может начать трассировать сама себя

4) трассируемая программа выполняет iretd, тоже самое

5) mov ss,xx/pop ss - подлая штука, может быть с префиксами

6) трассируемая программа выполняет intxx/int3/icebp

7) трассируемая программа выполняет into

8) трассируемая команда читает int1 descriptor

9) трассируемая команда пишет в int1 descriptor

10) трассируемая программа выполняет sidt (...)

11) трассируемая программа выполняет lidt

12) трассируемая команда пишет в dr6 (использует для хранения чисел/проверки)

13) вариации: трассируемая программа ставит свой обработчик INT1/3, начинает трассировать себя, ставит hardware-breakpoint'ы на чтение/запись/выполнение, использует исключения для своих нужд и всё это работает одновременно, причем приоритет выполнения играет роль.

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

[что еще нужно для отладчика]

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

1) Независимость от операционной системы. Это необходимо для обеспечения универсальности дизассемблера и для работы его в любых условиях (в том числе даже без загруженного в память ядра системы).

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

3) Очень важна корректность дизассемблирования нестандартных опкодов (содержащих многократные префиксы, недопустимые значения байта Mod R/M или SIB), так как такие команды любят применять в защитах для обмана дизассемблеров и эмуляторов.

4) Дизассемблер должен работать с кодом покомандно, то есть работать с отдельно взятой командой, не опираясь при этом на окружающий контекст. Устанавливать связь между командами - это задача кодоанализатора, который может при необходимости присутствовать в отладчике.

5) Желателен (но не обязателен) малый размер дизассемблера и возможность его использования в программах, написанных на различных языках программирования (С++, Delphi etc).

6) Открытый исходный код (так как тебе может понадобиться что-либо изменить в дизассемблере).

Если для решения твоей задачи нужен полноценный дизассемблер, удовлетворяющий всем вышеприведенным требованиям, то ты можешь использовать мой CADt (Code Analization and Disassemblibg Tool), выполненный в виде библиотеки, которую можно использовать в любых своих программах. Он поддерживает полный набор команд процессора Pentium 3 (386, MMX, SSE) и предназначен специально для использования в отладчиках и кодоанализаторах. Он полностью независим от системы и может использоваться как и в программах, работающих в третьем кольце, так и в отладчиках уровня ядра. В дистрибутив дизассемблера входит две версии DLL (одна предназначена для ring3, другая - для драйверов), объектные файлы для статической линковки, заголовочные файлы для Borland Delphi и MS Visual C++, исходный код дизассемблера на паскале (компилируется во всех версиях Delphi, начиная c третьей) и примеры применения дизассемблера в программах. Размер скомпилированной библиотеки - 18 кб, а то ты, наверно, уже испугался, думая, что дизассемблер на Delphi будет весить не меньше мегабайта.

Если же тебе будет достаточно дизассемблера длин, то можешь использовать мою библиотеку LDasm. Она поддерживает все существующие наборы инструкций для 32-битных x86 совместимых процессоров (386, MMX, SSE, SSE2, SSE3, 3DNow) и распространяется в исходных текстах. Несомненно, тебя порадует то, что исходники этого дизассемблера доступны в версиях на C++, Delphi, Visual Basic и на ассемблере, - выбирай, что тебе по вкусу. Оба дизассемблера ты можешь найти на диске с журналом, либо скачать с моего сайта ms-rem.dot-link.net. Если ты найдешь в них какую-нибудь ошибку, то обязательно сообщи мне на мыло, и я ее тотчас же ликвидирую.

[Debug API]

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

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

Для того чтобы отлаживать какой-либо процесс, его надо сначала запустить специальным образом. Делается это с помощью CreateProcess с установленными в dwCreationFlags флагами DEBUG_PROCESS и DEBUG_ONLY_THIS_PROCESS. Первый будет означать, что процесс запускается в режиме отладки, а второй - что отладка не распространяется на все запускаемые им дочерние процессы. Процесс запустится уже в остановленном состоянии, сгенерируется первое отладочное событие, несущее информацию о срабатывании брекпоинта, и процесс не будет выполняться до тех пор, пока это событие не будет обработано отладчиком. Для получения отладочного события дебаггер должен вызвать функцию WaitForDebugEvent, которая ждет наступления события и сохраняет информацию о нем в структуре TDebugEvent. Для продолжения выполнения программы отладчику нужно вызвать ContinueDebugEvent.

После запуска процесса мы сразу же получим событие, описывающее сработавший брекпоинт, но он будет не на точке входа в программу, а в функции BaseProcessStart из kernel32.dll, с которой начинается выполнение программы. Нам нужно установить «бряк» на точке входа. Для того чтобы определить, где она находится придется загрузить исполнимый файл программы и прочитать интересующий нас адрес из PE-заголовка. Для этого можно использовать функцию LoadLibraryEx:

pImage := pointer(LoadLibraryEx(PChar(DbgApp), 0, DONT_RESOLVE_DLL_REFERENCES));

ntHeaders := pointer(dword(pImage) + pImage^._lfanew);

EntryPoint := pointer(ntHeaders^.OptionalHeader.ImageBase + ntHeaders^.OptionalHeader.AddressOfEntryPoint);

FreeLibrary(dword(pImage));

Флаг DONT_RESOLVE_DLL_REFERENCES означает, что нам нужно просто загрузить PE-файл без настройки его импортов и исполнения точки входа. Без указания этого флага файл грузиться просто не будет. В случае успеха функция LoadLibraryEx возвращает параметр hmodule, который на самом деле является ни чем иным, как указателем на MZ-заголовок файла. Прибавив к нему значение поля e_lfanew этого заголовка, мы получим указатель на ImageNtHeaders, откуда можно извлечь базовый адрес загрузки PE-файла (ImageBase) и RVA его точки входа (AddressOfEntryPoint). Сумма этих чисел и будет адресом точки входа в загруженной программе. После получения точки входа файл можно выгрузить с помощью FreeLibrary, так как он больше не понадобится.

Теперь мы можем начать обработку отладочных событий. В первом из них установим брекпоинт, записав с помощью WriteProcessMemory байт CC (опкод команды int 3) на точку входа программы.

while true do

begin

WaitForDebugEvent(DbgEvent, INFINITE);

if DbgEvent.dwDebugEventCode = EXCEPTION_DEBUG_EVENT then

case DbgEvent.Exception.ExceptionRecord.ExceptionCode of

EXCEPTION_BREAKPOINT :

begin

Inc(BreakNum);

if BreakNum = 1 then

begin

ReadProcessMemory(Pr.hProcess, EntryPoint, @OldByte, 1, Bytes);

NewByte := $CC;

WriteProcessMemory(Pr.hProcess, EntryPoint, @NewByte, 1, Bytes);

end

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

Теперь ждем следующего события, которым будет являться наш брекпоинт. По его срабатыванию нам нужно убрать старый «бряк», установить флаг TF в регистре efl отлаживаемого потока, сместить eip на 1 байт назад (чтобы продолжить выполнение программы с ее точки входа) и продолжить выполнение потока. Для чтения регистров потока мы будем использовать GetThreadContext, а для записи - SetThreadContext.

Context.ContextFlags := CONTEXT_FULL;

GetThreadContext(Pr.hThread, Context);

Dec(Context.Eip);

Сontext.EFlags := Context.EFlags or $100;

SetThreadContext(Pr.hThread, Context);

WriteProcessMemory(Pr.hProcess, EntryPoint, @OldByte, 1, Bytes);

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

[для дизассемблирования кода используется дизассемблер CADt]

begin

if (Cmds > 0) then

begin

Context.ContextFlags := CONTEXT_FULL;

GetThreadContext(Pr.hThread, Context);

Context.EFlags := Context.EFlags or $100;

SetThreadContext(Pr.hThread, Context);

ReadProcessMemory(Pr.hProcess, pointer(Context.Eip), @Cmd, 32, Bytes);

DisCommand := DisasmCommand(@Cmd, Context.Eip);

writeln(DisCommand);

DisCommand := DisCommand + #13#10;

WriteFile(hFile, PChar(DisCommand)^, Length(DisCommand), Bytes, nil);

end;

Dec(Cmds);

end;

В итоге мы получим вполне работающий трейсер, который использует Debug API, умеет трассировать программу и ставить брекпоинты. Тестовый пример запускается из командной строки и принимает два параметра, первый из которых - путь к отлаживаемой программе, а второй - глубина трассировки (количество команд). Пример выводит дизассемблерный листинг в консоль и записывает его в файл c:\trace.log.

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

[эмуляция]

Эмуляция процессора - очень мощный метод отладки, не накладывающий вообще никаких ограничений на возможности отладчика, однако обладающий одним неприятным недостатком - для использования на реальных программах ему необходимо эмулировать не только процессор, но и все оборудование компьютера. Подобным эмулятором является виртуальная машина BOCHS. Это в настоящее время единственный отладчик-эмулятор пригодный для практических целей. Он обычно используется для отладки загрузчиков ОС, кода BIOS и других специфических случаев, недоступных для отладки обычными средствами. Для защищенных программ он, к сожалению, непригоден, так как не всегда корректно эмулирует специально составленные нестандартные команды, да и, надо заметить, существующий набор команд 386 эмулирует не полностью. К тому же, подобные эмуляторы легко обнаружить по специфическому набору эмулируемого оборудования. По моему мнению, создать необнаружаемый отладчик-эмулятор практически невозможно, так как в эмуляции процессора и оборудования есть множество мелких недокументированных тонкостей, которые не важны для работы обычных программ, а любое несоответствие поведения эмулируемого оборудования реальному может быть использовано защитой для обнаружения отладчика. Но для отладки операционных систем подобные эмулирующие дебаггеры незаменимы.

[обход ограничения]

Я думаю, ты уже заметил одно неприятное ограничение брекпоинтов устанавливаемых с помощью отладочных регистров - нельзя отслеживать чтение/запись участка памяти размером более четырех байт. Оказывается, существует метод, позволяющий обойти это ограничение. Если тебе известны принципы работы процессора в защищенном режиме со страничной адресацией, то для тебя не будет секретом, что память делится на страницы и в каждой странице могут быть назначены свои атрибуты доступа. Если запрашиваемый доступ к странице не совпадает с установленными атрибутами, то генерируется исключение общей защиты (имеющее номер 0E). Метод установки брекпоинтов по доступу к большим областям памяти заключается в установке на нужные страницы атрибутов доступа PAGE_NOACCESS, запрещающих обращения к этой странице, и в обработке возникающих при этом исключений. Любая команда, обращающаяся к обозначенной области памяти, будет вызывать исключение, его будет ловить отладчик, обрабатывать, после чего восстанавливать старые атрибуты доступа, и продолжать исполнение кода. Этот метод используется в IceExt (плагине SoftICE'а) в команде bpr.

 

(Администратор не несет ответственности (Автор Денис Евгеньевич)