Теоретические основы крэкинга: Глава 11. Трассировка во сне и наяву. — Архив WASM.RU
Трассировка - одна из основ, на которых держится крэкинг. О трассировке обычно говорят вскользь как о чем-то общеизвестном и само собой разумеющемся, но при этом имеющем нечеткие, плохо формализуемые правила. Читая предыдущие главы, Вы неоднократно встречали фразы "трассируем процедуру…", "трассируйте код до тех пор, пока…" и т.п., и при этом вряд ли задумывались о том, что нужно делать и как вообще трассируют код. Так что же такое отладка - наука это или искусство? Поставив себе целью разобраться в методах трассировки, мы прежде всего должны определиться, что такое трассировка и для чего она нужна. И лишь после того, как будут определены цели трассировки, возможно будет говорить о "технической" реализации методов, посредством которых эти цели могут быть достигнуты.
Прежде всего договоримся о терминологии. Само слово "трассировка" часто употребляется в различных смыслах - от синонима "отладки" до обозначения процесса пошагового исполнения программы (см. команды вроде "trace into" и "trace over" в некоторых отладчиках). Я не буду оригинален и введу еще одно значение слова "трассировка", которое и будет использоваться на протяжении данной главы: трассировка - это процесс определения "траектории" исполнения кода с некой заранее заданной точностью и наблюдение за изменениями во время исполнения этого кода. Вот такое определение - незамысловатое, но с замахом на глобальность. И, разумеется, без подробной расшифровки выглядящее достаточно туманно - как и любое другое достаточно широкое определение. Сам процесс трассировки может быть направлен на получение следующих знаний:
- Определение траекторий исполнения кода, как всех теоретически возможных, так и активирующихся только при неких начальных условиях.
- Наблюдение за изменениями каких-либо параметров (значений регистров, переменных, флагов) и определение их влияния на порядок исполнения команд.
- Нахождение точек ветвления и условий, приводящих к активации или деактивации того или иного участка траектории исполнения кода.
С пониманием того, что подразумевается под "заранее заданной точностью", я думаю, никаких сложностей у Вас не возникнет - эти слова означают всего лишь то, что при трассировке код анализируется исключительно лишь в той мере, которая нужна для решения практических задач. Если Вам необходимо полное понимание некоего алгоритма (то есть "заранее заданная точность" - максимальна), Вам придется анализировать код целиком; если же Вам нужен лишь ответ на вопрос "почему программа выдает сообщение "Неверный серийный номер" вместо поздравления с регистрацией", Вас скорее всего устроит экспресс-анализ четырех-пяти точек ветвления, находящихся "выше" процедуры, сообщающей об ошибке, а все, что происходит между этими точками, Вы вольны проигнорировать. Трассировка может заключаться в поиске двух-трех точек в программе и анализе десятка прилежащих к ним команд. Как такое возможно? Очень просто: для примера в качестве критерия "заранее заданной точности" мы берем факт чтения или записи данных в некую ячейку памяти и находим в листинге все явные обращения к соответствующему адресу. После этого мы можем смело сказать "при некоторых неизвестных условиях в данную ячейку могут быть записаны значения, равные нулю либо единице". Если речь идет о глобальном "флаге зарегистрированности", то весь дальнейший взлом сводятся к выяснению, какое из состояний означает зарегистрированность программы (автор программы мог проявить оригинальность, сделав код 1 признаком отсутствия лицензии) и исправлению одного бита в программе.
Иногда используется даже совсем уж вырожденный вариант трассировки: в коде программы выбирается некая контрольная точка, на нее ставится брейкпойнт, а затем при помощи тестового запуска определяется, проходит траектория исполнения программы через эту точку или нет. В основе всех приведенных примеров лежит трассировка, хотя степень детализации различается очень сильно. Каких-то готовых правил, позволяющих выбрать глубину "погружения в код", по-видимому, не существует - действовать приходится по ситуации. Однако на первых порах лучше применять "экспресс-анализ" и использовать трассировку не столько для исследования тонкостей работы кода, сколько для поиска логических блоков, поиска управляющих конструкций и понимания алгоритма в общих чертах. Именно такой подход лежит в основе дзен-крэкинга с его принципом "я не знаю, как это работает, но я могу это сломать", а если учесть, что таким "наскоком" вполне успешно ломаются недорогие утилиты, то этот метод, несомненно, будет серьезным подспорьем для начинающего крэкера.
Теперь рассмотрим, что представляет собой траектория исполнения кода. По традиции, разбирать это понятие мы будем на конкретном примере, а именно на куске кода, генерирующем таблицу констант для алгоритма CRC32:
Код (Text):
mov eax,255 _loop_i: mov edx,eax mov cl,8 _loop_j: shr edx,1 jnc @@1 xor edx,0EDB88320h @@1: dec cl jnz _loop_j mov [CRC32_Table+eax*4],edx dec eax jns _loop_iРаспечатайте этот код на листе бумаги (в принципе, можно выполнять все операции мысленно, но это будет не так удобно, как работать с "твердой копией"). Возьмите карандаш и напротив каждой из команд нарисуйте кружок - это будут точки возможной траектории исполнения кода. Теперь по полученным точкам попробуем построить совокупность всех возможных траекторий исполнения кода. Как известно, большинство процессоров исполняет команды последовательно (или же успешно изображают последовательное исполнение), одну за другой, по направлению от "нижних" адресов памяти к "верхним". И такой порядок исполнения может быть нарушен лишь командами переходов/возвратов, вызова прерываний, а также различными исключительными ситуациями. Вооружившись этим знанием, предположим, что исполнение данного куска кода начинается с команды mov eax,255 и начнем соединять наши кружочки стрелками в том порядке, в каком будут исполняться команды. Когда Вы доберетесь до команды jnc @@1, у Вас наверняка возникнет вопрос, куда нарисовать стрелку - ведь для флага SF может находиться в одном из двух возможных состояний, и в зависимости от этого состояния следующей исполняемой командой будет либо xor edx,0EDB88320h, либо dec cl. Выход из этой ситуации прост - рисуйте обе стрелки. После того, как Вы закончите это упражнение, должен получиться приблизительно такой рисунок:
Для удобства понимания переходы "вперед" по коду (такой переход обнаружился только один) я вынес влево, а переходы "назад" - вправо. Что мы видим? Прежде всего - то, что в каждый кружок входит как минимум одна стрелочка, а это означает, что каждая из нарисованных нами команд получает управление явным образом. Если бы после завершения нашего высокохудожественного шедевра обнаружилось, что какой-либо кружок "висит в воздухе", это было бы веским основанием для проведения подробного расследования на тему "зачем нужен программный блок, который не получает управления явным образом" (техническую сторону таких расследований мы рассматривали в предыдущих двух главах). Далее: окинув беглым взглядом картинку, Вы легко заметите два цикла, один из которых вложен в другой и одну конструкцию ветвления, "обходящую" команду xor edx,0EDB88320h при выполнении некоего условия (в языках высокого уровня такая последовательность обычно реализуется конструкциями вида IF <условие> THEN <действия>). Вот так при помощи карандаша и бумаги можно за считанные минуты выделить логические единицы внутри довольно абстрактной процедуры. Несмотря на то, что метод кажется очень простым и даже в чем-то "игрушечным", в действительности такие схемы с кружочками-стрелочками очень удобны, особенно если Вы сравнительно недавно занялись исследованием программ, и Ваш глаз еще не натренирован на вычленение управляющих конструкций в бесконечных листингах. А если Вам не хочется слишком уж часто прибегать к помощи карандаша и бумаги, есть смысл обзавестись дизассемблером IDA - последние версии этого продукта тоже умеют проставлять стрелочки напротив кода (правда, там это реализовано не так удобно, как на нашем рисунке). Или же можно написать собственную программу, которая бы на основе листинга рисовала такие вот картинки; если не пытаться сразу создать дизассемблер (и не просто дизассемблер, а как минимум аналог W32Dasm), а анализировать уже готовые листинги, программа даже получится не слишком сложной. Надо сказать, что инструменты, способные на основе ассемблерного листинга построить картинку, подобную приведенной выше, в настоящее время очень редки и потому более чем востребованы общественностью. Кстати, наш набор стрелочек на самом деле - отнюдь не изобретение "для личного пользования", а наглядное изображение весьма научной штуковины под названием "граф" (имеется в виду математический термин, а не дворянский титул). Так что если Вам близок раздел математики под названием "теория графов", Вы можете попробовать приложить для анализа нашей картинки всю мощь этой теории; особенно актуально это для тех, кого заинтересует тема визуализации и автоматического анализа кода.
При желании можно пойти еще дальше: нарисованные кружки-команды разбить на логически связанные группы, обвести каждую группу прямоугольником или ромбиком, вписать внутрь этих фигур краткие комментарии и в итоге получить обыкновенную блок-схему (она же "flowchart" в англоязычной литературе), иллюстрирующую исследуемый код. С недавних пор IDA поддерживает и эту технику представления кода - но, как обычно, со своей "спецификой". Проще говоря, блок-схемы в IDA мало похожи на то, что обычно называется блок-схемами в учебниках информатики. Да и потому пользоваться создаваемыми в IDA графиками (которые на данный момент невозможно экспортировать ни в один формат) не так удобно, как "классическими" блок-схемами. Кроме того, на сегодняшний день блок, ответственный за отображение блок-схем в IDA, является сторонней разработкой и совершенно не интерактивен, т.е. возможности пользователя по активной работе с такими блок-схемами практически нулевые - даже чтобы написать комментарий к блоку, Вам придется распечатать схему на бумаге.
Оба этих метода, рисунок из кружочков со стрелочками и блок-схема, в действительности изображают одно и то же - команды и возможные порядки их исполнения, и являются "рабочим материалом" для самого древнего метода работы с листингом - трассировки в уме. Суть метода трассировки в уме очень проста: Вы на время превращаете свой мозг в некий "виртуальный процессор", и начинаете мысленно "исполнять" команды подобно тому, как это делал бы процессор настоящий. Надо отметить, что в старые времена, когда компьютеры были большими и медленными, а машинное время было ресурсом весьма ограниченным, именно трассировка в уме была основным способом "отладки" программ - и программисты просиживали часами над огромными листингами с карандашиками в руках, пытаясь определить, в какой точке программа уклонилась с пути истинного. Нечто подобное предстоит научиться делать и Вам - с той существенной разницей, что Вы в любой момент можете проверить свои теории при помощи отладчика. Разумеется, по сравнению с машиной Ваше "быстродействие" будет ничтожным, да и отслеживать состояние регистров и ячеек памяти у Вас вряд ли получится - но в этом и нет необходимости. Главной целью такого "мысленного исполнения кода" должно быть определение "ключевых точек", вычленение логических блоков внутри трассируемого кода, приблизительное определение назначения этих блоков и наблюдение за тем, как состояние регистров, флагов и переменных отражается на пути исполнения программы.
Как я уже говорил, обычно мы изучаем не весь код построчно, а лишь те его участки, которые могут привести к интересующим нас эффектам, причем задача чаще всего стоит следующим образом: по известному эффекту необходимо найти траекторию исполнения кода, которая приводит к появлению этого эффекта. Предположим, что Вы успешно обнаружили, где в программе расположена код реализации нужного эффекта (вывод MessageBox'а, запись единицы в регистр EAX и т.п.), и Вам хочется понять, каким образом программа передает управление на этот код и каким образом этого можно избежать или наоборот - получать такой результат при любых исходных данных. Для решения таких задач обычно используется обратная трассировка в уме. Идея обратной трассировки очень проста: мы начинаем читать листинг "задом наперед", то есть движемся от следствия (которое нам известно) к причине. По ходу дела отмечаем ключевые точки, к которым относятся:
- Вызовы функций Win32 API, а также других стандартных функций, какие сможет распознать Ваш дизассемблер или Вы сами.
- Обращения к глобальным переменным, которые чаще всего выглядят как чтение или запись данных по указанному явным образом адресу.
- Вызовы подпрограмм, непосредственно за которыми следует проверка некоего условия (внешне выглядят как связка команд CALL-CMP-Jxx).
Перечисленные три группы отличаются от всех прочих кодов тем, что их назначение сравнительно легко идентифицируется. Действительно, если Вы видите вызов API'шной функции чтения командной строки, для Вас будет очевидно, что следующие за вызовом команды почти наверняка будут оперировать именно с текстом командной строки, а не с фазами Луны или курсом доллара. И вот тому пример:
Код (Text):
call GetCommandLineA mov edi, eax cmp byte ptr [edi], 22h jnz short loc_401B08Нетрудно догадаться, что этот кусок кода проверяет, является ли первый символ командной строки двойной кавычкой. Далее, согласно принципам дзен-крэкинга, следует помедитировать о том, что может последовать за такой проверкой. А за ней обычно следует поиск закрывающей кавычки и дальнейший синтаксический разбор строки - пропуск разделяющих пробелов, вычленение нужного параметра из строки (обычно определяется смещение первого символа в строке и длина этого параметра) и т.п. Нечто подобное как раз и проделывал тот патч, из которого я взял код для примера. Для нас в данном случае интересен не подробный анализ кода, я хотел продемонстрировать несколько иное, а именно: как всего лишь один известный системный вызов позволяет приблизительно оценить назначение процедуры, в которой он содержится.
Причина интереса к глобальным переменным тоже достаточно очевидна. Современные программисты, как правило, придерживаются принципов структурного и объектного программирования, которые предполагают минимальное использование общедоступных объектов - каждая процедура должна "видеть" лишь те данные, которые ей нужны для работы. Поэтому разработчики программ обычно дают глобальный статус двум типам данных: тем, которые используются настолько широко, что их неудобно передавать в каждую процедуру, где они требуются (например: настройки программы, таблицы констант, тексты сообщений и т.п.), и всевозможным отладочным переменным, которые полагается удалять при выпуске релиза программы. Регистрационные данные нередко занимают промежуточное положение: с одной стороны, это своеобразная "настройка программы", а с другой они близки к отладочной информации в том смысле, что добавление защиты нередко производится уже после написания программы чисто механическим путем ("если переменная не равна нулю, то нарисовать поверх отчета слово UNREGISTERED").
Как Вы понимаете, использование глобальной переменной для хранения статуса программы в наше время - редкая и счастливая для крэкера случайность. Гораздо чаще разработчики, начитавшись руководств "как защитить свою программу от хакеров за 1 час", усваивают, что глобальную переменную использовать в качестве "переключателя" нехорошо. Вот функция, вызываемая к месту и не к месту - это совсем-совсем другое дело. Сказано-сделано, и в программе появляются многочисленные куски вида "if (!RegistaProggie()) ShowMessage ("Wanna getta munnee!")" (если кто не понял текст сообщения, приблизительный перевод с нетрадиционного английского звучит как: "хочу бабки!"). А во что такие куски превращаются при ассемблировании? Правильно - в цепочку CALL-CMP-Jxx, о которой я говорил парой абзацев выше. Вот такие-то интересные кусочки мы и будем высматривать при обратной трассировке. Разумеется, одним лишь поиском регистрационных процедур дело не ограничивается - цепочки "вызов-проверка-ветвление" могут быть проверкой на корректность введенных данных (как-то раз мне пришлось поправить программу игры "Жизнь", которая могла, но отчего-то не хотела работать с полями больше, чем 100*100), и конструкцией SWITCH (она же CASE в Паскале), да и много чем еще. Причем, если у функции есть параметры, они могут стать отличной подсказкой, позволяющей установить назначение этой функции (особенно хорошо этот прием работает с функциями, выполняющими преобразование строк). Для этого нужно под отладчиком исследовать, что именно передается в функцию и какой результат она возвращает. Нередко это даже оказывается проще, чем догадаться, что хранится в глобальной переменной.
Но вернемся к описанию алгоритма обратной трассировки в уме. Итак, мы встретили команду ветвления или перекрестную ссылку. Переходим к точке, куда/откуда ведет ссылка, а затем пытаемся определить, при каких условиях эта ссылка "срабатывает" и что происходит, когда условие срабатывания ссылки не выполняется, причем для "не сработавшей" ветки исходник нужно читать уже не "снизу вверх", а в порядке исполнения команд. Следуя этой схеме, Вы, скорее всего, доберетесь до начала либо до конца процедуры, если не запутаетесь во всех этих ветвлениях и переходах. По сути, алгоритм обратной трассировки в уме рекурсивен (команды условного перехода или перекрестные ссылки часто порождает два возможных пути чтения кода), а человеческое сознание мало приспособлено к выполнению рекурсивных алгоритмов "вручную", так что Вам лучше сначала потренироваться на простых примерах.
Добравшись до начала процедуры либо до точки выхода из нее, Вам придется либо анализировать все возможные варианты, откуда и почему могла быть вызвана процедура (т.е. пробежаться по всем ссылкам на эту процедуру), либо прибегнуть к отладчику. Второй путь, конечно, нарушит "чистоту идеи" трассировки в уме, но зато даст ответ максимально простым и быстрым способом. Теоретически процесс обратной трассировки в уме Вы можете продолжать до тех пор, пока не доберетесь до начала программы, но на практике мне редко приходилось подниматься по "дереву процедур" более чем на четыре уровня вверх. Нельзя не отметить одну тонкость: даже если Вы нашли точку выхода из процедуры, убедитесь, что эта точка единственная: компиляторы могут генерировать код с более чем одним выходом из процедуры\функции, причем эти "дальние" ветки могут быть даже более интересными, чем "ближние". Дело в том, что любая проверка может быть многоступенчатой: к примеру, сначала проверяется, введены ли вообще какие-либо данные, затем - насколько эти данные корректны, и лишь в последнюю очередь - соответствуют ли эти данные какому-либо узкоспециализированному критерию.
Трассируя программы, Вам придется немало поводить пальцем по распечатке или экрану дисплея, взбираясь по дереву вызовов и переходов. А если к тому же Ваш дизассемблер "не умеет" выделять цветом команды переходов, Вы можете запросто пропустить что-нибудь важное. В общем, блуждание по бескрайним полям кода - дело, требующее внимания, сосредоточенности, и при этом достаточно утомительное. Однако если Вы решаете типовую задачу "как заставить программу выполнить некий код независимо от правильности исходных данных", Вам может помочь все та же карта всех возможных путей исполнения кода, построение которой я демонстрировал в самом начале статьи. Как только Вы составите такую карту, Вам останется лишь выполнить три несложных действия:
- Выбрать в программе и отметить на этой карте исходную точку. В качестве исходной точки лучше всего выбирать код, имеющий непосредственное отношение к исследуемой защите, который может быть легко идентифицирован и вызван из программы. Если речь идет о старых добрых серийных номерах, вводимых с клавиатуры, то лучше всего искать код чтения серийника из окна - этот код обычно легко "ловится" в отладчике и заведомо вызывается защитой (надо же ей как-то узнавать, с какими параметрами пользователь пытается зарегистрироваться).
- Отметить конечную точку, путь к которой требуется найти (например, вызов MessageBoxA, сообщающий об успешной регистрации).
- Найти на карте путь (а лучше - все возможные пути) из начальной точки в конечную.
Как только такой путь будет найден, можете начинать соответствующим образом патчить код и выяснять, что из этого получится. Интересно, что выполнять второй пункт можно не только вручную (это чем-то похоже на детскую головоломку "найди путь в лабиринте"), но и в автоматизированном режиме - в этом случае потребуется решить типовую задачу из курса теории графов. Теоретически это позволяет поставить взлом защит, использующих только переключатель "зарегистрировано/не зарегистрировано" даже не на поток - на конвейер!
Даже если у Вас возникнут проблемы с вторым пунктом (например, программа никак не сообщает об успешной регистрации или же Вы просто не знаете, как это сообщение выглядит и потому не можете его найти), это не повод для отчаяния. Пусть Вы не знаете, на каком из возможных путей исполнения находится нужный Вам код - зато никто не отнимет у Вас знания о том, на каком пути этот код не находится! А значит, последовательно форсируя при помощи отладчика исполнение каждой возможной ветки кода, Вы можете постепенно отбрасывать "неправильные" ветки, пока не наткнетесь на правильную (или не придете к выводу о невозможности решить Вашу задачу таким способом).
Попробовав трассировать в уме сколько-нибудь сложную процедуру, вырванную из середины программы, Вы заметите, что хотя возможные пути исполнения кода и известны, но сказать, какой путь изберет программа, если ее запустить, Вы не можете. И никто не может - поскольку при трассировке в уме Вам неизвестны исходные данные, которые и заставляют программу выбирать из всех возможных путей единственный актуальный. Именно здесь и пролегает граница между разглядыванием препарированного "мертвого" кода и вождения пальцем по распечатке и наблюдением за "живой" программой, обрабатывающей "настоящие" данные. Так что пришло время поговорить о методах трассировки программ "вживую".
Исторически первым способом трассировки "вживую" был многократный вызов в отладчике функции "исполнить текущую команду" с заходом в подрограммы или без такового. Проще говоря, программист сидел перед монитором и давил нужную кнопку, наблюдая, как бегает по коду курсор, и меняются значения регистров. Более продвинутые (или просто более ленивые) программисты смекнули, что для определения траектории важны не все команды, а лишь те, в которых программа делает выбор, в какую сторону ей "свернуть". А потому для определения траектории нужно наставить брейкпойнтов на команды условных вызовов и переходов, а все, что находится между этими командами, можно исполнять в автоматическом режиме. Интерес программиста был прост: наблюдая за траекторией "забега" программы, отследить момент, когда программа уклонится в неправильную сторону, а потом найти причину отклонения и исправить программу так, чтобы программа "бегала" по предназначенному ей пути. Обычно отслеживание траектории исполнения по "ключевым точкам" использовалось для получения ответов на вопросы "после какой точки программа начинает работать некорректно", а пошаговое исполнение - чтобы точно определить, какие именно команды формируют неверные данные. И методы эти за все годы, прошедшие со времен их появления, ничуть не утратили актуальность и широко применяются в практически неизменном виде.
Технический прогресс не стоял на месте: расставлять брейкпойнты и гонять по ним программу вручную было неудобно, и программистам не могла не прийти в голову мысль "а почему бы не усовершенствовать отладчик таки образом, чтобы собственно трассировка выполнялась в автоматическом режиме". Прообразом современных средств трассировки был режим "анимации" (то есть замедленного исполнения) программы - при некотором навыке в мелькании содержимого регистров и переменных можно было попытаться отловить полезную информацию и "притормозить" программу в нужный момент. Впрочем, пользы от этого режима в те времена было немного - изучать в режиме анимации сложные программы было неудобно, а анимировать программы, работающие с графикой - и вовсе невозможно (в те времена отладчики функционировали исключительно в текстовом режиме). Однако в некоторых современных отладчиках режим анимации все-таки сохранился, и, надо отметить, толку от него куда больше, чем во времена DOS. Значительно возросшее быстродействие современной техники превратило анимацию из слайд-шоу для особо терпеливых, сопровождавшегося лихорадочным мерцанием экрана, в весьма динамичное действо, наблюдение за которым доставляет лишь удовольствие. Некоторые люди считают, что исполнение кода в режиме анимации никакой практической пользы не приносит, но я придерживаюсь несколько иного мнения: в этом режиме хорошо заметны длинные циклы, а также циклы со счетчиком, особенно если счетчик расположен в одном из регистров. Начинающим, думаю, интересно будет "вживую" понаблюдать работу распаковщика исполняемых программ - лучше один раз увидеть работу несложной процедуры распаковки, чем сто раз прочитать "книжное" описание этого процесса. Для просмотра этого шоу лучше всего взять небольшую программу, сжатую UPX'ом, поскольку "навесная" процедура распаковки UPX - одна из самых простых, и даже если у Вас не получится полностью разобраться в ней при помощи отладчика/дизассемблера, все сложные моменты можно разъяснить по исходным текстам, находящимся в свободном доступе.
Читая эту главу, Вы могли заметить, что многие из описанных методов, несмотря на всю их внешнюю простоту, довольно неудобны для практического применения, поскольку требуют от пользователя отличной памяти и внимательности, а также способностей к рекурсивному "чтению" кода. Попробуйте "пробежаться" отладчиком по достаточно сложной процедуре, при этом строя траекторию исполнения кода в уме. Если у Вас получилось - значит, либо процедура оказалась не очень сложной, либо большинству людей остается лишь позавидовать Вашей памяти. Так или иначе, но программисты пришли к идее автоматической трассировки, то есть пошагового исполнения программы с одновременным "запоминанием" всех сделанных шагов. Основным препятствием на пути к осуществлению этой идеи долгое время был сравнительно небольшой объем оперативной памяти старых ЭВМ и недостаточная мощность процессора. Даже такой старый процессор, как 8086, способен был выполнять десятки и сотни тысяч команд в секунду - представьте, какой объем памяти потребовался бы, чтобы запомнить всего лишь последовательность адресов исполненных команд, не говоря уже о состоянии регистров. Кроме того, на одну команду, выполненную отлаживаемой программой в режиме трассировки, приходятся десятки и сотни команд, выполненных отладчиком - и отсюда возникает заметное падение производительности трассируемой программы. В общем, до некоторого времени реализация такого способа трассировки машинного кода была практически нереальна. Однако когда объем памяти компьютеров начал измеряться мегабайтами, воплощение этой идеи наконец стало возможным. Режим трассировки появился сначала в SoftIce, а затем и в OllyDebug, причем по возможностям трассировки и связанных с ней функций OllyDebug определенно превзошел все прочие известные автору отладчики.
Прежде всего следует отметить, что OllyDebug, в отличие от SoftIce, запоминает трассировочную информацию более "интеллектуально" - то есть сохраняет не только список адресов команд, но и модификации регистров. К сожалению, хранить информацию обо всех изменениях в адресном пространстве процесса OllyDebug не может (это потребовало бы совершенно невообразимого объема ОЗУ), но если обращение к переменной производится по указателю на нее, отладчик вполне способен запомнить значение регистра-указателя. Такое поведение отладчика облегчает задачу отслеживания состояний регистров: если Вас интересует, в какой момент том или ином регистре "появилось" некое число, Вы можете решить эту задачу простым поиском этого числа в текстовом файле. Да-да, именно в текстовом файле - OllyDebug обладает совершенно уникальной на сегодняшний день возможностью сохранять практически любые промежуточные данные из отладчика на жесткий диск, и в число таких данных входит отчет об исполнении программы в режиме трассировки. Вы можете безо всяких ухищрений сохранить список всех исполненных команд, их адресов, а также всю дополнительную информацию об изменениях в регистрах. Пример такого отчета Вы можете увидеть ниже:
Код (Text):
Address Thread Command Registers and comments Flushing gathered information 01006AEC Main xor ebx, ebx EBX=00000000 01006AEE Main push ebx pModule = NULL 01006AEF Main mov edi, dword ptr ds:[<&KERNEL32.GetModuleHandleA>] EDI=77E7AD86 01006AF5 Main jnz short NOTEPAD.01006B16 EAX=01000000 01006AF7 Main mov ecx, dword ptr ds:[eax+3C] 01006AFC Main add ecx, eax 01006AFE Main cmp dword ptr ds:[ecx], 4550 ECX=000000E8 01006B01 Main jnz short NOTEPAD.01006B15 ECX=010000E8Как распорядиться столь подробным отчетом - зависит только от Ваших целей и изобретательности. Я могу лишь подсказать самые общие направления поиска. Прежде всего, стоит проанализировать значения, находящиеся в первой колонке, то есть адреса команд и "наложить" список адресов выполненных команд на листинг дизассемблирования программы (для W32Dasm, который формирует этот листинг в виде текстового файла, технически осуществить это несложно). Такое слияние "мертвого" листинга с отчетом отладчика о работе "живого" кода способно значительно облегчить понимание того, в какой точке программа повела себя "не так", и как наставить ее на путь истинный. Также бывает удобным "спроецировать" конкретную траекторию исполнения кода на схему всех возможных путей исполнения кода (например, обвести часть стрелочек красным карандашом) - сочетая этот прием с патчингом кода и/или модификацией данных для принудительной активации тех или иных траекторий, можно последовательно отсеивать траектории, не ведущие к желаемой цели.
Здесь следует сделать небольшое отступление и рассказать об одном принципиальном преимуществе текстового представления дизассемблерных листингов перед всевозможными упакованными двоичными форматами. Текст - это один из самых старых и универсальных способов передачи информации, и для его обработки создано огромное количество всевозможных утилит. Инструменты работы с текстовыми файлами сами по себе очень стары, но, тем не менее, практически не устаревают, алгоритмы, в них использующиеся, "вылизаны" и доведены до совершенства поколениями программистов, и потому было бы неразумно отвергать столь огромный пласт программистской культуры. Конечно, использование узкоспециализированных двоичных форматов в дизассемблерах позволяет сэкономить дисковое пространство, ускорить обработку данных и хранить вместе с листингом различную дополнительную информацию, например, списки перекрестных ссылок, но за это приходится расплачиваться неоправданной интеграцией собственно дизассемблера и средства просмотра дизассемблированного текста. А просмотрщики дизассемблерных листингов, увы, обычно проектируются по остаточному принципу. Если рассматривать наиболее популярные в настоящее время W32Dasm и IDA Pro, то можно обнаружить не всегда удобную навигацию, ориентацию на исключительно текстовый режим работы (как я уже говорил, построение блок-схем в IDA Pro реализовано в виде программы сторонних разработчиков, не поддерживающей интерактивную работу с кодом), и некоторые другие недостатки. Авторы дизассемблеров, впрочем, вполне осознают недостатки двоичных форматов и полезность прямой работы с текстом, а потому предусмотрели возможность экспорта в текстовый формат и даже "обычного" текстового поиска в окне дизассемблера. В IDA Pro реализован даже более сложный вариант поиска с использованием регулярных выражений и специальный скриптовый язык, который по идее должен дать пользователю возможность самому добавить в дизассемблер недостающую функциональность. Однако хороший просмотрщик текстов, набор двоичных утилит *NIX'ового происхождения и навыки в программировании способны творить с "сырыми" листингами такие чудеса, какие традиционным дизассемблерам и не снились - от простого просмотра с подсветкой синтаксиса до форматирования "лесенкой" текстов на ассемблере. А поскольку наш отчет о трассировке как раз имеет текстовый формат и является, по сути, специфической формой дизассемблерного листинга, к нему вполне применимы все изложенные выше соображения о работе с текстом.
Поиск повторяющихся последовательностей адресов позволяет обнаружить программные циклы, а также узнать, сколько раз эти циклы выполнились. Ну и, разумеется, Вы сможете ответить на вопрос, который начинающие весьма часто задают, но на который весьма редко получают ответ. Вопрос этот такой: "как мне узнать, когда в регистре появляется нужное число?" (иногда встречалась еще более странная формулировка: "как установить брейкпойнт на регистр"). Простой поиск нужного числа в тексте отчета позволит Вам ответить на этот вопрос, хотя полезность такого действия в большинстве случаев весьма сомнительна (по крайней мере, у меня ни разу не возникало такой потребности).
Увы, в каждой бочке меда есть своя ложка дегтя, а в нашем случае - даже не одна. При пошаговой отладке (а, стало быть, и в процессе трассировки - тоже) накладные расходы на исполнение одной команды в десятки и сотни раз превышают собственно время исполнения команды, из-за чего скорость исполнения кода в режиме трассировки падает катастрофически. О расходах памяти на хранение информации о порядке исполнения команд я уже упоминал, однако это еще не все: если внутри отладчика эта информация подвергается упаковке для более компактного хранения, то когда Вы сохраняете эту информацию на диск в виде текста, результирующий файл может получиться просто огромным. И он будет тем огромнее, чем большее количество команд было исполнено в режиме трассировки. Так что в ближайшем будущем нам, увы, не светит возможность запустив программу под отладчиком в режиме трассировки, беспрепятственно снимать с нее "жизненные показатели", выискивая подозрительные значения регистров и "нехорошие" команды переходов. Трассировка - это метод, пригодный для анализа сравнительно небольших кусков кода, чего, впрочем, обычно более чем достаточно. Также есть принципиальные проблемы с трассировкой программ, активно работающих с "железом", многопоточных программ и с софтом, использующим коммуникацию между процессами - все эти группы ПО, даже не содержащие защит, во время трассировки могут вести себя очень капризно. © CyberManiac
Теоретические основы крэкинга: Глава 11. Трассировка во сне и наяву.
Дата публикации 13 апр 2005