Теоретические основы крэкинга: Глава 10. Слишком хорошо – тоже не хорошо. — Архив WASM.RU
Как Вы уже успели убедиться, обращение с брейкпойнтами – занятие далеко не такое простое, как может показаться с первого взгляда. В предыдущей главе мы разбирались в том, почему точки останова иногда не делают того, что мы от них ожидаем, значительная же часть этой главы посвящена полностью противоположной проблеме: что делать, если точки останова срабатывают слишком часто. После всего написанного в предыдущей главе такая ситуация может показаться Вам невероятной, но, представьте себе, такое тоже бывает.
Если Вы входите в число поклонников отладчика SoftIce, то Вы не могли не заметить одну милую особенность этого инструмента: если Вы поставили брейкпойнт непосредственно на системную функцию, SoftIce будет «всплывать» при каждой попытке исполнить эту функцию независимо от того, внутри какого процесса функция была вызвана. Такое поведение отладчика, несомненно, бывает полезным при отладке драйверов, хук-процедур и самой операционной системы. Но наша беда (или счастье – это уж с какой стороны посмотреть) в том, что мы отлаживаем «обычную» программу, и постоянные выпадения в отладчик по совершенно неинтересным поводам – это далеко не то, о чем мы мечтали. Вот если бы удалось сделать так, чтобы брейкпойнты работали внутри только одного процесса… Те, кто начал знакомиться с SoftIceпо пакету NuMegaDriverStudio 2.6, скорее всего не увидят в этом никакой проблемы: BPX <имя_функции> IFPID=<ID_нужного_процесса> - и дело в шляпе. Набрав это заклинание, они, в общем-то, будут полностью правы, ибо это вполне хороший и, в общем-то, самый короткий путь к цели. И я никогда не скажу дурного слова про тех, кто последует этим путем. Однако этот путь – далеко не единственный из возможных, и если Вам интересны иные способы решения проблемы постановки брейкпойнта – добро пожаловать в музей истории SoftIce.
Мое общение с этим отладчиком началось с версии 3.23 для Windows 9x, случайно обнаруженной на свежем «хакерском» диске. Пусть по нынешним меркам тот СофтАйс совершенно не производит впечатления - тогда это был шедевр! Впрочем, то был шедевр не без недостатков, самым главным из которых была его неразборчивость в срабатывании брейкпойнтов. Как я отмечал, поставить брейкпойнт на функцию WinAPI, срабатывающий исключительно внутри нужного процесса (по-научному брейкпойнты с такими свойствами называются «address-contextsensitive», они же «контекстно-зависимые»), напрямую было невозможно. Чтобы почувствовать, насколько серьезной была проблема, представьте себе следующую картину: бряк, поставленный на функции чтения из реестра (которая называется RegQueryValue[Ex], надеюсь, Вы еще не забыли мой «поминальник»), работает настолько хорошо, что отлавливает абсолютно все попытки чтения из реестра, независимо от того, выполняет их отлаживаемая Вами программа или какая-либо другая. Любые попытки осмысленной отладки в такой ситуации заведомо обречены на провал, единственное, чем Вы будете заниматься – это нажимание клавиш Ctrl-D, ибо Windowsнастолько сильно любит читать данные из реестра, что делает это много раз в секунду (если хотите своими глазами посмотреть на эту странную любовь, RegMon Вам в этом поможет). Так что же, поставить бряк на RegQueryValueExи получить от этого удовлетворительный результат совсем никак невозможно? Как бы не так! «Если нельзя, но очень хочется, то все-таки можно».
Начнем с небольшого, но очень важного определения: адресный контекст (он же контекст процесса) – это все виртуальное адресное пространство, выделенное данному процессу.
«Военная хитрость», при помощи которой мы «проапгрейдим» контекстно-независимый брейкпойнт до контекстно-зависимого, основана на том факте, что хотя SoftIceи игнорирует контекст в момент срабатывания контекстно-независимых брейкпойнтов (в число которых входят и брейкпойнты на функции WinAPI), сам текущий адресный контекст от этого никуда не исчезает. И потому в момент срабатывания условного брейкпойнта отладчик вполне способен прочитать любые данные из существующего в этот момент виртуального адресного пространства. Проще говоря, если брейкпойнт сработал внутри программы X, то отладчик сможет «увидеть» адресное пространство программы X вместе со всеми данными, содержащимися в этом пространстве, а заодно и содержимое всех регистров, каким оно было в момент срабатывания брейкпойнта. А если отладчик «видит» все адресное пространство процесса и способен читать исполняемый код программы, значит, можно попытаться идентифицировать процессы по особенностям их исполняемого кода! На практике эта «идентификация по особенностям исполняемого кода» выглядит весьма прозаично: нужно забраться при помощи шестнадцатиричного редактора внутрь секции кода (впрочем, для идентификации можно использовать и любые другие заведомо не изменяющиеся при работе программы данные), выдернуть оттуда первый попавшийся DWORD (назовем его My_DWORD) и запомнить виртуальный адрес (My_Addrсоответственно), по которому этот DWORDнаходился. Дальнейшие операции, выполняемые уже в отладчике, ненамного сложнее: BPXимя_функции_WinAPIIF(*My_Addr)==My_DWORD. Все.
Думаю, с пониманием того, что делает эта команда, ни у кого сложностей не возникло: мы соорудили условную точку останова, которая в момент срабатывания проверяет, не лежит ли по адресу My_Addrдвойное слово, равное My_DWORD. И если такое двойное слово по нужному адресу обнаруживается, то отладчик приостанавливает исполнение программы. Поскольку случайно встретить пару программ, у которых в секции кода по одним и тем же виртуальным адресам находились бы одинаковые DWORD’ы, вряд ли возможно, такой простейший способ различения процессов в абсолютном большинстве случаев отлично срабатывает. Сложности могут возникнуть только в двух случаях: при отладке упакованных программ и если нужно параллельно отлаживать два экземпляра («экземпляр» следует понимать как «instance») одного и того же приложения – поскольку код обеих процессов идентичен, в общем случае различить их по содержимому адресного пространства затруднительно.
В настоящее время применение описанного выше метода для определения, в контексте какого из процессов сработал брейкпойнт, в общем-то, не требуется – в современных версиях SoftIce(вообще, сам отладчик SoftIce, особенности работы с ним и различия между разными его версиями – это отдельная большая тема, которая будет закрыта не раньше, чем прекратится развитие самого SoftIce) эта проблема решается безо всяких ухищрений. Однако поскольку особенности отдельных инструментов имеют весьма слабое отношение к теоретическим вопросам крэкинга, которым посвящена данная работа, я в дальнейшем не буду акцентировать внимание на этих особенностях – разобравшись в предлагаемом материале, Вы сами найдете способ с максимальной эффективностью использовать эти особенности. Для того же, чтобы избежать путаницы при изложении материала этой главы, мы будем считать, что все брейкпойнты, о которых ниже пойдет речь, являются контекстно-зависимыми, то есть работают исключительно в рамках того процесса, в котором они установлены. Таким образом, мы «уравняем в правах» SoftIceи отладчики третьего кольца защиты («ring3 debuggers»), к которым, в частности, относится OllyDebug.
Само по себе создание условного брейкпойнта, срабатывающего исключительно при появлении по некоему адресу известного значения – прием весьма часто употребляемый и полезный во множестве ситуаций. Самое известное из применений этой техники: «остановить программу, если переменная равна некоему значению» мы рассматривать не будем по причине крайней его банальности, такие вещи Вы способны проделывать самостоятельно, и, возможно, даже с закрытыми глазами. Куда менее очевидна возможность использования брейкпойнтов чтобы «притормозить» упакованную программу сразу после начала ее исполнения. Возможно, Вы уже встречали в статьях по крэкингу сокращение «OEP», которое расшифровывается как OriginalEntryPoint, «оригинальная («истинная») точка входа». Возможно, Вы также читали о том, что распаковка сжатых исполняемых файлов включает в себя поиск адреса этой самой OEP. Если же Вы ничего такого еще не читали и не встречали, то Вам необходимо запомнить следующие базовые сведения:
- 1. Entrypoint – это точка, с которой начинается исполнение программы после загрузки. Адрес точки входа в Win32-приложениях хранится в PE-заголовке исполняемого файла.
- 2. Абсолютное большинство упаковщиков и навесных защит изменяют значение адреса точки входа таким образом, чтобы управление передавалось распаковщику или защитному модулю соответственно.
- 3. Originalentrypoint – это entrypointисполняемого файла до того, как файл был сжат/зашифрован и значение точки входа было модифицировано упаковщиком.
- 4. Для успешной распаковки программы в общем случае требуется узнать адрес OEP, остановить программу в тот момент, когда распаковщик передаст управление на OEP и в этот момент снять дамп со всех секций процесса. Также довольно часто после снятия дампа приходится восстанавливать таблицу импорта, в некоторых случаях может потребоваться ручная правка параметров секций.
Один из приемов отыскания OEPзаключается в следующем: нужно поставить на функции WinAPIбрейкпойнты таким образом, чтобы один из них сработал заведомо недалеко от точки входа в программу. Выяснив, откуда был произведен этот первый вызов WinAPI’шной функции, Вы сделаете первый (и самый важный) шаг на пути к адресу OEP. После этого обратной трассировкой в уме или каким-либо иным методом Вы будете «раскручивать» программу, как бы заставляя ее исполняться «задом наперед», и таким образом шаг за шагом подбираясь к OEP. Идея, лежащая в основе этого метода поиска OEP, крайне проста: современные компиляторы устроены так, что в коде абсолютного большинства программ неподалеку от точки входа содержится как минимум один вызов функции WinAPI (обычно такой функцией является GetModuleHandle или GetCommandLine).
Другой важный момент состоит в том, что практически каждый компилятор генерирует в начале программы специфический «начальный» (startup) код, который производит некие действия по инициализации данных, необходимых для работы стандартных библиотек. Причем этот код у разных компиляторов (а часто – у разных версий одного и того же компилятора или даже при разных опциях компиляции) имеет достаточно специфический и легко узнаваемый вид, причем повлиять на содержание этого startup-кода разработчик программы обычно не может (или может, но в ограниченных пределах). Поэтому чтобы Вам было легче искать OEP, я настоятельно рекомендую изучить, какой код помещают в начало программы наиболее распространенные компиляторы – так Вы быстро научитесь определять OEPисключительно по внешнему виду кода, находящегося в окрестностях первого вызова функции WinAPI.
Однако как отличить вызов нужной нам функции из только что распакованной программы от всех прочих вызовов этой же функции, которые могут выполняться в процессе работы распаковщика? Как раз для этого Вы можете воспользоваться проверкой значения, находящегося по некому известному адресу внутри кода программы (обозначим этот адрес буквой A). Извлечь нужное значение дампером из запущенной программы обычно не составляет никакой сложности. Поскольку известные мне упаковщики распаковывают код в направлении от меньших адресов к большим (и с достаточной точностью можно предположить, что так работает большинство распаковщиков), лучше выбирать эталонное значение ближе к концу программы. После того как такое значение у Вас будет, нужно лишь поставить условную точку останова на нужную функцию, а в качестве дополнительного условия указать равенство значения по адресу Aэталонному значению, которое Вы извлекли из распакованной программы. В результате Ваш брейкпойнт сработает не раньше, чем будет распакована соответствующая часть программы, так что довольно велика вероятность того, что, продолжая отладку в пошаговом режиме, Вы вернетесь в код программы, а не в недра распаковщика.
Наблюдая за работой запакованных программ, Вы могли заметить, что они после распаковки всегда располагаются в памяти по одним и тем же адресам. Применив против такой программы какую-нибудь утилиту вроде ProcDump, Вы даже можете прочитать параметры секций программы, частности - начальный адрес и размер. После этого вполне естественным кажется вопрос: «а что, если проверять, принадлежит ли адрес возврата, лежащий на стеке, промежутку адресов, в котором расположен код программы?» И вопрос этот отнюдь не праздный. Дело в том, что нередки упаковщики и навесные защиты, в которых блок, выполняющий дешифровку и распаковку кода, располагается в области, не пересекающейся с той, в которой в итоге будет расположен исполняемый код программы. Поэтому, поставив брейкпойнт на функцию и в качестве условия указав что-нибудь вроде ([esp]>401000) && ([esp]<501000) Вы добьетесь того, чтобы Ваш брейкпойнт активизировался лишь в том случае, если при выходе из функции предполагается возврат в код отлаживаемой программы. Данный метод, также как и описанный выше, может использоваться для поиска OEPчерез обратную трассировку от первого вызова функции WinAPI. Однако этим возможности условных точек останова, проверяющих адрес возврата, отнюдь не ограничиваются.
Очень часто возникает необходимость посмотреть параметры вызова некой часто используемой функции в тех случаях, когда вызов этой функции был сделан из одной или нескольких точек, и при этом проигнорировать все прочие вызовы (которых может быть очень много). Если решать задачу «в лоб», то необходимо найти все ссылки на данную функцию и установить по брейкпойнту на каждый интересующий нас вызов. Обычно такой подход вполне удовлетворителен, но мы не будем искать легких путей и посмотрим, как ту же задачу можно решить при помощи одного единственного брейкпойнта. Вы уже знаете, что для распознавания, откуда был сделан вызов функции, можно использовать адрес возврата, лежащий на вершине стека, и потому без труда напишете соответствующее условие для брейкпойнта. Такое условие может выглядеть следующим образом:
Код (Text):
([esp]==ret_addr1) || ([esp]==ret_addr2) || …,гдеret_addr1 иret_addr2 – адресавозврата. Все это достаточно очевидно, и Вы можете задать вопрос, зачем нужно было изобретать очередной велосипед, если традиционный подход дает ничуть не худшие результаты? Первая причина - «человеческий фактор»: работать с большим количеством точек останова не всегда удобно даже в насквозь визуальном OllyDebug, а уж «рулить» десятком-другим брейкпойнтов в SoftIce– занятие, что называется, на любителя. Так что если есть возможность значительно уменьшить число брейкпойнтов и облегчить себе жизнь, почему бы этой возможностью не воспользоваться? Кроме того, перед Вами может встать задача, обратная по отношению к вышеприведенной: отслеживать все вызовы процедуры, за исключением нескольких. И в этом случае сформировать строку с условием вида
Код (Text):
([esp]!=ret_addr1) && ([esp]!=ret_addr2) && …,в которой ret_addr1, ret_addr2 – адреса возврата, при которых точка останова не должна срабатывать, представляется гораздо более простым, чем искать в коде программы все подозрительные ссылки на интересующую Вас процедуру и «обвешивать» их брейкпойнтами.
Однако более важным представляется применение условных брейкпойнтов для обнаружения неочевидных вызовов функций. Я уже рассказывал в предыдущей главе о приемах, при помощи которых разработчик защиты может вызывать процедуры неявным образом, из-за чего такие обращения к процедурам становятся «невидимы» для дизассемблеров и прочих инструментов анализа «мертвого кода». В качестве дополнительного средства маскировки авторами защит могут параллельно использоваться как обычные вызовы процедур, так и неочевидные; я встречал такое в приложении к MessageBoxAи к функции чтения из реестра, но вообще эта техника может быть применена к любой достаточно часто вызываемой процедуре, используемой в защитных механизмах. В результате в дизассемблированном коде мы увидим несколько ничем не примечательных явных вызовов – но, скорее всего, не заметим самого интересного. Поставив «обычный» брейкпойнт на вызываемую функцию, мы можем столкнуться с тем, что эта функция вызывается десятки раз, и потому тоже не сможем определить, задействована ли эта функция в защитном механизме – интересующие нас неявные вызовы потеряются среди десятков и сотен вызовов явных. Вот если бы был какой-нибудь способ отделить явные вызовы от неявных…
И такой способ есть. Базовые идеи, используемые для различения явных и неявных вызовов, могут быть сформулированы следующим образом:
1. Практически все явные вызовы функций легко обнаруживаются дизассемблером или вспомогательными инструментами для поиска ссылок (к примеру, в OllyDebugэта операция элементарно выполняется из контекстного меню: Findreferencesto|selectedcommand). Построив список явных вызовов, можно записать условие-фильтр, в котором будут перечислены все адреса возврата после явных вызовов, и использовать этот фильтр в качестве условия срабатывания для точки останова. Проще говоря, нам нужно будет установить условный брейкпойнт с фильтром вида ([esp]!=ret_addr1) && ([esp]!=ret_addr2) && …, который бы «пропускал» явные вызовы, но срабатывал на всех остальных, то есть неявных.
2. Набор «штатных» команд ассемблера, предназначенных для вызова подпрограмм, сравнительно невелик, поэтому, проанализировав несколько байт, предшествующих адресу возврата, можно строить предположения о способе вызова. В частности, если опкод, находящийся на пять байт «выше» адреса возврата, равен 0E8h (CALLxxxx), скорее всего вызов был сделан стандартным способом. Если же в окрестностях адреса возврата ни одна из разновидностей команды CALL не обнаружена – либо вызов был неявным, либо внутри отработавшей процедуры присутствовал код, искажающий адрес возврата.
3. Код, предшествующий вызову подпрограммы, может оставлять легко идентифицируемые следы: определенные состояния флагов, значения переменных или соотношения между регистрами. В некоторых случаях, проанализировав эти следы, можно делать выводы, из какой точки могла (или не могла) быть вызвана та или иная подпрограмма.
Поясню эту мысль на следующем коде:
Код (Text):
cmp eax, ebx ja _no_call call MyProc _no_call: …Нетрудно заметить, что если вызов процедуры MyProcбыл сделан из вышеприведенного куска кода, то в момент входа в процедуру значение регистра eaxдолжно быть меньше либо равно ebx (в противном случае выполнилась бы команда ja, обходящая call). И если это соотношение между регистрами в каких-то случаях окажется недействительным, для крэкера будет совершенно очевидно, что в этих «подозрительных» случаях вызов подпрограммы был произведен откуда угодно, но только не из продемонстрированного кода.
Разумеется, при большом желании, возможно построить код программы таким образом, что приложение вышеприведенные идей не даст положительного результата. Однако это потребует от программиста дополнительных и довольно серьезных усилий – разработчику придется проанализировать собственный код так, как это сделал бы крэкер (и отнюдь не факт, что это у него получится – принципиально разные цели порождают разный образ мышления). Затем все найденные уязвимые участки придется переписать, причем наверняка - на ассемблере, поскольку высокоуровневые языки обычно не дают необходимой гибкости. После всего этого все переписанные связки между процедурами придется заново отладить протестировать, чтобы убедиться в полной корректности работы исправленного кода. В общем, объем работ нешуточный, а результат… Результат, как водится, заведомо неизвестен – никто не может гарантировать, что разработчик не упустит какой-либо важный момент, или крэкер не найдет особо нетрадиционный подход, после которого все усилия автора защиты пойдут прахом. Так что обычно разработчики обычно не озабочиваются сокрытием «тонких эффектов» при неявных вызовах, и Вы, при случае, можете обратить это себе на пользу.
Увы, в настоящее время применить вторую и третью идею в полном объеме, мы обнаружим ограниченность имеющихся программных средств. Да, наши любимые отладчики отлично справляются с относительно простыми фильтрами, однако более глубокий анализ ситуации в момент срабатывания брейкпойнта, невозможен либо, по меньшей мере, крайне затруднителен. Действительно, попробуйте написать условие, которое проверяло бы наличие всех возможных разновидностей команды CALLв районе адреса возврата – и Вы поймете, что я имею в виду. А ведь иногда возникает необходимость не только (и не столько) остановить программу при тех или иных значениях регистров, но в автоматическом режиме собрать статистику по срабатыванию брейкпойнта – частота появления тех или иных адресов возврата, типичные значения параметров функции, на которую поставлен брейкпойнт и тому подобное. И тут становится очевидным, что даже «статистические» команды SoftIceи модификатор DOв командах установки брейкпойнтов являются лишь слабым подобием того, в чем рано или поздно возникает потребность у каждого крэкера. Идеальным решением была бы встраивание в отладчики собственного скриптового языка, обеспечивающего полный доступ ко всем возможностям отладчика (что уже частично реализовано в плагинах для OllyDebugи различных «сторонних утилитах» для SoftIce). Если же существующие реализации средств скриптинга не предоставляют необходимых возможностей, мы будем вынуждены обходиться программными «затычками», реализация которых аналогична устройству описанных в предыдущей главе точек останова в Spectrum’овских отладчиках. Поскольку применяются такие «брейкпойнты» (а по сути - патчи) довольно широко, а с необходимостью «перехватить» исполнение программы в нужной точке рано или поздно сталкивается любой крэкер, мы подробно рассмотрим эту технологию в главе, посвященной патчингу.
До настоящего момента мы как-то обходили вниманием точки останова, срабатывающие при попытке доступа к определенным областям памяти вообще, и аппаратные брейкпойнты в частности. Вы, возможно, даже начали беспокоиться из-за того, что, говоря о точках останова, я так долго не упоминал волшебное слово «BPM». И вот пришло время поближе узнать, что такие брейкпойнты собой представляют и какую практическую пользу из них можно извлечь. «Законный» способ установки брейкпойнтов на области памяти основывается на использовании специальных отладочных регистров, обозначаемых как DR0-DR7. Каждый из брейкпойнтов может отслеживать любой (но только один) из следующих типов обращения к памяти: запись, чтение, запись или чтение, исполнение кода. Операции «чтение» и «исполнение» процессор считает принципиально разными, несмотря на то, что здравый смысл говорит нам: прежде чем исполнить код, нужно его прочитать. «Это невозможно понять, это нужно запомнить» - поэтому временно отложите здравый смысл в сторонку и запомните это правило. По этой же причине одновременно отслеживать запись, чтение и исполнение при помощи одного-единственного брейкпойнта у Вас не получится. Это первое существенное ограничение, наложенное инженерами из Intelна использование отладочных регистров.
Впервые отладочные регистры появились в процессорах 80386 именно для отслеживания обращений к памяти, но в Pentiumвозможности этих регистров были распространены и на порты ввода-вывода. Поскольку собственно адреса точек останова (или номера портов, обращение к которым будет отслеживаться) задаются в регистрах DR0-DR3, таких брейкпойнтов может быть не более четырех – это второе ограничение. Еще одна проблема состоит в том, что отладочные регистры позволяют установить брейкпойнт только на байты, слова (WORD) или двойные слова (DWORD), аппаратных брейкпойнтов на обращения к более крупным блокам памяти не предусмотрено. Если Вам нужно отследить обращения к переменным «длинных» нецелочисленных типов (Double, Extended), Вы моежете поставить брейкпойнт в середину переменной; в этом случае любое обращение к такой переменной «зацепит» брейкпойнт. Также важно помнить следующее: если Вы устанавливаете бряк на слово, адрес брейкпойнта будет автоматически выровнен на ближайший «снизу» четный адрес, а если Вам нужен бряк на DWORD – приготовьтесь к тому, что процессор выровняет адрес брейкпойнта на адрес, кратный четырем.
И, наконец, самый неприятный для крэкера факт – работа с отладочными регистрами возможна только из нулевого кольца защиты, так что любителям запустить свои шаловливые ручки в недра системы и сотворить там что-нибудь эдакое придется сначала повозиться с написанием соответствующего софта. Впрочем, пусть Вас согревает тот факт, что разработчикам защит добраться до отладочных регистров и испортить Вам удовольствие будет ничуть не проще. Если у Вас возник живой интерес к теме внутреннего устройства аппаратных точек останова, могу порекомендовать обратиться к первоисточникам, то бишь к фирменной документации Intel.
Пожалуй, пора заканчивать с живописаниями грядущих трудностей и прочими ужасами, а то, добравшись до последней главы этой работы, Вы можете решить, что крэкинг – это очень сложно, больно и трудно (хотя в действительности это не совсем так). Поэтому давайте лучше устроим сеанс позитивного мышления и погрузимся в созерцание всего того доброго, светлого и прекрасного, которое привносят в нашу жизнь аппаратные брейкпойнты. Ибо, воистину, тяжела была бы наша жизнь, не будь в ней аппаратных точек останова.
Нетрудно догадаться, что изначально аппаратные брейкпойнты предполагалось применять для поиска ошибок, связанных с некорректной работой с переменными, и отлавливать ситуации, чреватые переполнением буфера (для этого сразу после последнего байта буфера ставился «защитный» брейкпойнт, срабатывание которого сигнализировало о попытке записать или прочитать лишние данные). Однако крэкеры даже столь милым и безобидным штукам, как аппаратные точки останова, нашли нетрадиционное применение, превратив их в главную «ударную силу» против самомодифицирующегося и упакованного кода.
Если Вы пробовали поставить брейкпойнт на упакованный код, Вы не могли не заметить, что после этого действа программа отчего-то перестает корректно распаковываться (впрочем, с вероятностью приблизительно 1/256, программа распакуется даже после такого издевательства, но брейкпойнт, разумеется, работать не будет). Прочитав предыдущую главу, Вы наверняка осознали всю глубину и тяжесть Вашей ошибки, чистосердечно раскаялись в этом ужасном деянии и, положив руку на руководство по отладчику, трижды произнесли торжественное обещание никогда больше так не поступать. А потом, подобно классику, задались вопросом: «что делать?» Разыскивая ответ на этот глубоко философский вопрос, Вы могли обнаружить в руководстве по SoftIceраздел, повествующий о команде BPMи ее параметрах, либо добраться до таинственных пунктов Breakpoint|Hardware, on… в контекстном меню OllyDebug. Если Вы еще не проделали этих операций – прочитайте документацию по отладчику, посмотрите, как правильно ставить аппаратные точки останова на чтение, запись и исполнение и немного потренируйтесь на первой попавшейся программе, дабы убедиться, что такие точки останова действительно существуют и даже работают. А потом освежите в памяти эксперимент, в котором мы при помощи дампера наблюдали изменения в коде, вызываемые командой BPX, и попытайтесь повторить его над к аппаратными точками останова. Как и следовало ожидать, аппаратные брейкпойнты не оставляют в коде программы никаких следов. Их не видно – но наши маленькие аппаратные друзья существуют и работают! И как бы программа ни утюжила свой код проверками, сколько бы ни высчитывала контрольные суммы – против аппаратных точек останова эти приемы бесполезны, так что теперь Вы сможете сколько угодно отлаживать программу, не опасаясь, что защитные процедуры в один миг изничтожат любовно расставленные Вами бряки.
Интересно, что аппаратные брейкпойнты отлично работают не только с «обычными» участками памяти, но и со стеком. В принципе, ничего удивительного в этом нет – память, зарезервированная под стек, с точки зрения процессора и ОС ничем принципиально не отличается от памяти, предназначенной для кода или данных. Однако для некоторых начинающих крэкеров, разум которых опутан предрассудками, сама идея установки аппаратного брейкпойнта на чтение/запись данных в стек выглядит чем-то странным и противоестественным. А ведь установка аппаратного брейкпойнта на стек – это отнюдь не оригинальничание, а вполне действенная техника, часто используемая при «ручной» распаковке сжатых программ, а также позволяющая обнаруживать подмену адресов возврата в стеке.
Знания об аппаратных брейкпойнтах позволяют нам по-новому взглянуть на проблему подмены адресов возврата. Вспомните последний пример из предыдущей главы – тот, где функция A вызывает функцию B, функция Bвызывает функцию C, а функция Cделает «финт ушами», подменяя адрес возврата, и возвращается в функцию D (а не в функцию B). Теперь, когда Вы вооружены необходимой информацией о том, как отслеживать обращения к определенным адресам памяти, для Вас не составит труда обнаружить попытки процедуры подправить свой адрес возврата – просто «накройте» брейкпойнтом на чтение/запись двойное слово, хранящее адрес возврата, и Вы без проблем найдете команду, которая выполняет подмену.
Аппаратные точки останова внутри стека могут помочь Вам победить еще один довольно неприятный защитный прием – переход со «сбросом» части стека. В вышеприведенном примере демонстрировалось искажение адреса возврата, лежащего на стеке, однако автор защиты может и не возиться с искажением адресов, а волевым решением «выбросить» со стека часть параметров, переменных и адресов возврата (это может быть сделано, к примеру, командой ADDESP,xxxx или несколькими PUSH’ами), после чего переход в нужную точку кода выполнить простым JMP. Поразмыслив над содержимым стека, Вы даже можете приблизительно определить границы стековых фреймов. Если предположить, что стековые фреймы «сбрасываются» целиком (в принципе, это не обязательно, но такой код проще в отладке), то Вы можете составить список возможных значений регистра ESPпосле «сброса» фреймов. Затем методом «научного тыка» протестируйте каждое из этих значений, устанавливая аппаратные брейкпойнты на чтение/запись двойного слова из каждого из этих значений ESP, а также на 4 байта ниже; можно также добавить брейкпойнты на предполагаемые адреса локальных переменных. В результате Вы получите одну из трех ситуаций:
- Если после «сброса» стека программа попытается выполнить возврат из процедуры, она «споткнется» о брейкпойнт, стоящий по адресу ESP.
- Если программа попытается вызвать подпрограмму, и при этом поместит какое-либо значение (параметр или адрес возврата) на стек, команда, выполняющая запись данных в стек, вызовет срабатывание брейкпойнта по адресу ESP-4.
- Если брейкпойнт установлен на локальную переменную, исполнение программы будет прервано при первом же обращении к этой переменной.
Смысл всех этих действия заключается в том, чтобы как можно раньше остановить программу после «сброса» стека и узнать, что пытался скрыть автор защиты при помощи этого приема. Поскольку современные программы стеком пользуются достаточно активно, можно надеяться, что первое срабатывание нашей «ловушки» будет не слишком далеко отстоять от той точки, в которую был выполнен переход. Если выяснится, что адресов, на которых срабатывают ловушки, несколько, Вам нужно выбрать тот из них, обращение к которому выполняется раньше других – очевидно, что он наиболее близок к искомой точке.
Описанный метод также традиционно используется для определения OEPупакованных программ: заклинание «BPMESP-4», которое следует набирать в SoftIceсразу после загрузки распаковываемой программы, так или иначе упоминается в большинстве статей, посвященных ручной распаковке кода. А вот смысл сего заклинания, увы, поясняется намного реже, и сейчас я попытаюсь исправить это недоразумение. Реализация абсолютного большинства навесных защит такова, что значение ESPв момент завершения работы защитного модуля и передачи управления на OEPв точности равно значению ESPсразу после загрузки запакованной программы. Причины этого лежат где-то в глубинах сознания разработчиков защит, поскольку уменьшение значения ESPперед исполнением программы на величину, кратную четырем, никаких отрицательных эффектов не вызывает, поскольку место под стек обычно резервируется с запасом (а вот увеличение значения ESPуже может быть чревато). Однако большинство упаковщиков считают, что значение ESPлучше передавать пользовательскому коду в неизменном виде, а потому свято блюдут принцип «сколько на стек положено – столько должно быть снято», причем окончательная коррекция стека скорее всего будет проведена непосредственно перед выполнением перехода на OEP. И если эта коррекция выполняется командами чтения данных из стека (POP, POPADи т.п.), а не простой записью в ESPранее сохраненного значения, Ваш брейкпойнт сработает в этот знаменательный момент. Таким образом, Вам останется лишь отсеять ложные срабатывания брейкпойнта, если таковые будут, и трассировать код до тех пор, пока управление не будет передано из распаковщика в основную программу.
Мой рассказ об аппаратных точках останова был бы неполон, если бы я не упомянул об одной специфической разновидности брейкпойнтов останавливающих исполнение программы при обращении к произвольной области памяти. Такие брейкпойнты в SoftIceпод ОС Windows 9xсоздавались при помощи команды BPR(в современных версиях этого отладчика команда BPR, к сожалению, отсутствует, что особенно странно в свете того, что в арсенале OllyDebugтакие точки останова имеются). Внешне использование таких брейкпойнтов ничем не отличается от работы с обычными аппаратными точками останова, если не считать возможности «накрыть» брейкпойнтами практически неограниченное количество участков памяти совершенно любого размера (что выгодно отличает данный тип брейкпойнтов от «обычных» аппаратных, которых может быть не более четырех). Гораздо больший интерес представляет знание о том, каким образом реализован этот тип брейкпойнтов, которое, возможно, пригодится Вам в будущем (к примеру, если Вы захотите написать собственный отладчик).
Как Вы знаете, процессоры линейки x86 в защищенном режиме (некоторые источники называют этот режим расширенным) содержат множество средств, облегчающих создание многозадачных программ и позволяющих защитить данные от некорректных операций над ними. Для нас особенно интересной представляется возможность изменять атрибуты защиты отдельных страниц памяти, то есть разрешать или запрещать определенный тип действий (запись, чтение, исполнение кода) над информацией, хранящейся на той или иной странице памяти. При этом любая попытка выполнить запрещенную операцию, к примеру, записать данные на страницу, для которой запись запрещена, вызовет исключительную ситуацию. Более того, чтобы ради этого нехитрого действа Вам не пришлось выбираться в нулевое кольцо защиты, в WindowsAPIвключены функции VirtualProtectи VirtualProtectEx, позволяющие изменять атрибуты страниц памяти (правда, флаг запрета на исполнение кода на платформе x86 бесполезен, но необходимости в таком запрете обычно и не возникает). Как видите, создать «область останова» не так уж трудно, основную сложность в реализации таких «областей останова» представляет обработка исключительных ситуаций, возникающих при обращении к данным из защищенной области. Основным препятствием в практической реализации является то, что обычно размеры «области останова» не выровнены на границы страниц и кратны размерам страницы. По этой причине возникает необходимость в написании достаточно изощренного кода, распознающего, к какой ячейке памяти произошло обращение и обеспечивающего корректное продолжение работы программы после ошибки нарушения прав доступа к странице (по сути требуется написать нечто среднее между простым дизассемблером и виртуальной машиной).
Уместно будет упомянуть, что некоторые защиты манипулируют атрибутами страниц, чтобы противодействовать отладке, memorypatching’у и снятию дампов. Большинству патчеров и дамперов эти ухищрения глубоко безразличны, однако если Вы планируете заняться написанием собственных утилит для патчинга и/или снятия дампов (что я Вам настоятельно рекомендую), обязательно проверьте, как Ваши изделия будут реагировать на нестандартные атрибуты страниц. Поэтому если у Вас возникнут какие-либо проблемы при операциях с памятью чужого процесса, есть смысл поинтересоваться атрибутами страниц, с которыми Вы работаете (это можно сделать при помощи той же VirtualAllocEx).
На этом наше знакомство с теорией и практическими аспектами применения точек основа в основном закончено, и теперь мы можем во всеоружии приступить к рассмотрению методов трассировки кода. Именно этому и будет посвящена следующая глава.
© CyberManiac
Теоретические основы крэкинга: Глава 10. Слишком хорошо – тоже не хорошо.
Дата публикации 17 мар 2005