Слишком хорошо – тоже не хорошо.



 

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

 

Если Вы входите в число поклонников отладчика SoftIce, то Вы не могли не заметить одну милую особенность этого инструмента: если Вы поставили брейкпойнт непосредственно на системную функцию, SoftIce будет «всплывать» при каждой попытке исполнить эту функцию независимо от того, внутри какого процесса функция была вызвана. Такое поведение отладчика, несомненно, бывает полезным при отладке драйверов, хук-процедур и самой операционной системы. Но наша беда (или счастье – это уж с какой стороны посмотреть) в том, что мы отлаживаем «обычную» программу, и постоянные выпадения в отладчик по совершенно неинтересным поводам – это далеко не то, о чем мы мечтали. Вот если бы удалось сделать так, чтобы брейкпойнты работали внутри только одного процесса… Те, кто начал знакомиться с SoftIce по пакету NuMega DriverStudio 2.6, скорее всего не увидят в этом никакой проблемы: BPX <имя_функции> IF PID=<ID_нужного_процесса> - и дело в шляпе. Набрав это заклинание, они, в общем-то, будут полностью правы, ибо это вполне хороший и, в общем-то, самый короткий путь к цели. И я никогда не скажу дурного слова про тех, кто последует этим путем. Однако этот путь – далеко не единственный из возможных, и если Вам интересны иные способы решения проблемы постановки брейкпойнта – добро пожаловать в музей истории SoftIce.

 

Мое общение с этим отладчиком началось с версии 3.23 для Windows 9x, случайно обнаруженной на свежем «хакерском» диске. Пусть по нынешним меркам тот СофтАйс совершенно не производит впечатления - тогда это был шедевр! Впрочем, то был шедевр не без недостатков, самым главным из которых была его неразборчивость в срабатывании брейкпойнтов. Как я отмечал, поставить брейкпойнт на функцию WinAPI, срабатывающий исключительно внутри нужного процесса (по-научному брейкпойнты с такими свойствами называются «address-context sensitive», они же «контекстно-зависимые»), напрямую было невозможно. Чтобы почувствовать, насколько серьезной была проблема, представьте себе следующую картину: бряк, поставленный на функции чтения из реестра (которая называется RegQueryValue[Ex], надеюсь, Вы еще не забыли мой «поминальник»), работает настолько хорошо, что отлавливает абсолютно все попытки чтения из реестра, независимо от того, выполняет их отлаживаемая Вами программа или какая-либо другая. Любые попытки осмысленной отладки в такой ситуации заведомо обречены на провал, единственное, чем Вы будете заниматься – это нажимание клавиш Ctrl-D, ибо Windows настолько сильно любит читать данные из реестра, что делает это много раз в секунду (если хотите своими глазами посмотреть на эту странную любовь, RegMon Вам в этом поможет). Так что же, поставить бряк на RegQueryValueEx и получить от этого удовлетворительный результат совсем никак невозможно? Как бы не так! «Если нельзя, но очень хочется, то все-таки можно».

 

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

 

«Военная хитрость», при помощи которой мы «проапгрейдим» контекстно-независимый брейкпойнт до контекстно-зависимого, основана на том факте, что хотя SoftIce и игнорирует контекст в момент срабатывания контекстно-независимых брейкпойнтов (в число которых входят и брейкпойнты на функции WinAPI), сам текущий адресный контекст от этого никуда не исчезает. И потому в момент срабатывания условного брейкпойнта отладчик вполне способен прочитать любые данные из существующего в этот момент виртуального адресного пространства. Проще говоря, если брейкпойнт сработал внутри программы X, то отладчик сможет «увидеть» адресное пространство программы X вместе со всеми данными, содержащимися в этом пространстве, а заодно и содержимое всех регистров, каким оно было в момент срабатывания брейкпойнта. А если отладчик «видит» все адресное пространство процесса и способен читать исполняемый код программы, значит, можно попытаться идентифицировать процессы по особенностям их исполняемого кода! На практике эта «идентификация по особенностям исполняемого кода» выглядит весьма прозаично: нужно забраться при помощи шестнадцатиричного редактора внутрь секции кода (впрочем, для идентификации можно использовать и любые другие заведомо не изменяющиеся при работе программы данные), выдернуть оттуда первый попавшийся DWORD (назовем его My _ DWORD) и запомнить виртуальный адрес (My _ Addr соответственно), по которому этот DWORD находился. Дальнейшие операции, выполняемые уже в отладчике, ненамного сложнее: BPX имя_функции_WinAPI IF (*My_Addr)==My_DWORD. Все.

 

Думаю, с пониманием того, что делает эта команда, ни у кого сложностей не возникло: мы соорудили условную точку останова, которая в момент срабатывания проверяет, не лежит ли по адресу My_Addr двойное слово, равное My_DWORD. И если такое двойное слово по нужному адресу обнаруживается, то отладчик приостанавливает исполнение программы. Поскольку случайно встретить пару программ, у которых в секции кода по одним и тем же виртуальным адресам находились бы одинаковые DWORD’ы, вряд ли возможно, такой простейший способ различения процессов в абсолютном большинстве случаев отлично срабатывает. Сложности могут возникнуть только в двух случаях: при отладке упакованных программ и если нужно параллельно отлаживать два экземпляра («экземпляр» следует понимать как «instance») одного и того же приложения – поскольку код обеих процессов идентичен, в общем случае различить их по содержимому адресного пространства затруднительно.

 

В настоящее время применение описанного выше метода для определения, в контексте какого из процессов сработал брейкпойнт, в общем-то, не требуется – в современных версиях SoftIce (вообще, сам отладчик SoftIce, особенности работы с ним и различия между разными его версиями – это отдельная большая тема, которая будет закрыта не раньше, чем прекратится развитие самого SoftIce) эта проблема решается безо всяких ухищрений. Однако поскольку особенности отдельных инструментов имеют весьма слабое отношение к теоретическим вопросам крэкинга, которым посвящена данная работа, я в дальнейшем не буду акцентировать внимание на этих особенностях – разобравшись в предлагаемом материале, Вы сами найдете способ с максимальной эффективностью использовать эти особенности. Для того же, чтобы избежать путаницы при изложении материала этой главы, мы будем считать, что все брейкпойнты, о которых ниже пойдет речь, являются контекстно-зависимыми, то есть работают исключительно в рамках того процесса, в котором они установлены. Таким образом, мы «уравняем в правах» SoftIce и отладчики третьего кольца защиты («ring3 debuggers»), к которым, в частности, относится OllyDebug.

 

Само по себе создание условного брейкпойнта, срабатывающего исключительно при появлении по некоему адресу известного значения – прием весьма часто употребляемый и полезный во множестве ситуаций. Самое известное из применений этой техники: «остановить программу, если переменная равна некоему значению» мы рассматривать не будем по причине крайней его банальности, такие вещи Вы способны проделывать самостоятельно, и, возможно, даже с закрытыми глазами. Куда менее очевидна возможность использования брейкпойнтов чтобы «притормозить» упакованную программу сразу после начала ее исполнения. Возможно, Вы уже встречали в статьях по крэкингу сокращение «OEP», которое расшифровывается как Original Entry Point, «оригинальная («истинная») точка входа». Возможно, Вы также читали о том, что распаковка сжатых исполняемых файлов включает в себя поиск адреса этой самой OEP. Если же Вы ничего такого еще не читали и не встречали, то Вам необходимо запомнить следующие базовые сведения:

1. Entry point – это точка, с которой начинается исполнение программы после загрузки. Адрес точки входа в Win32-приложениях хранится в PE-заголовке исполняемого файла.

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

3. Original entry point – это entry point исполняемого файла до того, как файл был сжат/зашифрован и значение точки входа было модифицировано упаковщиком.

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. Однако этим возможности условных точек останова, проверяющих адрес возврата, отнюдь не ограничиваются.

 

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

([esp]==ret_addr1) || ([esp]==ret_addr2) || …,

где ret_addr1 и ret_addr2 – адреса возврата. Все это достаточно очевидно, и Вы можете задать вопрос, зачем нужно было изобретать очередной велосипед, если традиционный подход дает ничуть не худшие результаты? Первая причина - «человеческий фактор»: работать с большим количеством точек останова не всегда удобно даже в насквозь визуальном OllyDebug, а уж «рулить» десятком-другим брейкпойнтов в SoftIce – занятие, что называется, на любителя. Так что если есть возможность значительно уменьшить число брейкпойнтов и облегчить себе жизнь, почему бы этой возможностью не воспользоваться? Кроме того, перед Вами может встать задача, обратная по отношению к вышеприведенной: отслеживать все вызовы процедуры, за исключением нескольких. И в этом случае сформировать строку с условием вида

([esp]!=ret_addr1) && ([esp]!=ret_addr2) && …,

в которой ret_addr1, ret_addr2 – адреса возврата, при которых точка останова не должна срабатывать, представляется гораздо более простым, чем искать в коде программы все подозрительные ссылки на интересующую Вас процедуру и «обвешивать» их брейкпойнтами.

 

Однако более важным представляется применение условных брейкпойнтов для обнаружения неочевидных вызовов функций. Я уже рассказывал в предыдущей главе о приемах, при помощи которых разработчик защиты может вызывать процедуры неявным образом, из-за чего такие обращения к процедурам становятся «невидимы» для дизассемблеров и прочих инструментов анализа «мертвого кода». В качестве дополнительного средства маскировки авторами защит могут параллельно использоваться как обычные вызовы процедур, так и неочевидные; я встречал такое в приложении к MessageBoxA и к функции чтения из реестра, но вообще эта техника может быть применена к любой достаточно часто вызываемой процедуре, используемой в защитных механизмах. В результате в дизассемблированном коде мы увидим несколько ничем не примечательных явных вызовов – но, скорее всего, не заметим самого интересного. Поставив «обычный» брейкпойнт на вызываемую функцию, мы можем столкнуться с тем, что эта функция вызывается десятки раз, и потому тоже не сможем определить, задействована ли эта функция в защитном механизме – интересующие нас неявные вызовы потеряются среди десятков и сотен вызовов явных. Вот если бы был какой-нибудь способ отделить явные вызовы от неявных…

 

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

1. Практически все явные вызовы функций легко обнаруживаются дизассемблером или вспомогательными инструментами для поиска ссылок (к примеру, в OllyDebug эта операция элементарно выполняется из контекстного меню: Find references to|selected command). Построив список явных вызовов, можно записать условие-фильтр, в котором будут перечислены все адреса возврата после явных вызовов, и использовать этот фильтр в качестве условия срабатывания для точки останова. Проще говоря, нам нужно будет установить условный брейкпойнт с фильтром вида ([esp]!=ret_addr1) && ([esp]!=ret_addr2) && …, который бы «пропускал» явные вызовы, но срабатывал на всех остальных, то есть неявных.

 

2. Набор «штатных» команд ассемблера, предназначенных для вызова подпрограмм, сравнительно невелик, поэтому, проанализировав несколько байт, предшествующих адресу возврата, можно строить предположения о способе вызова. В частности, если опкод, находящийся на пять байт «выше» адреса возврата, равен 0E8h (CALL xxxx), скорее всего вызов был сделан стандартным способом. Если же в окрестностях адреса возврата ни одна из разновидностей команды CALL не обнаружена – либо вызов был неявным, либо внутри отработавшей процедуры присутствовал код, искажающий адрес возврата.

 

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

 

Поясню эту мысль на следующем коде:

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). Теперь, когда Вы вооружены необходимой информацией о том, как отслеживать обращения к определенным адресам памяти, для Вас не составит труда обнаружить попытки процедуры подправить свой адрес возврата – просто «накройте» брейкпойнтом на чтение/запись двойное слово, хранящее адрес возврата, и Вы без проблем найдете команду, которая выполняет подмену.

 

Аппаратные точки останова внутри стека могут помочь Вам победить еще один довольно неприятный защитный прием – переход со «сбросом» части стека. В вышеприведенном примере демонстрировалось искажение адреса возврата, лежащего на стеке, однако автор защиты может и не возиться с искажением адресов, а волевым решением «выбросить» со стека часть параметров, переменных и адресов возврата (это может быть сделано, к примеру, командой ADD ESP,xxxx или несколькими PUSH’ами), после чего переход в нужную точку кода выполнить простым JMP. Поразмыслив над содержимым стека, Вы даже можете приблизительно определить границы стековых фреймов. Если предположить, что стековые фреймы «сбрасываются» целиком (в принципе, это не обязательно, но такой код проще в отладке), то Вы можете составить список возможных значений регистра ESP после «сброса» фреймов. Затем методом «научного тыка» протестируйте каждое из этих значений, устанавливая аппаратные брейкпойнты на чтение/запись двойного слова из каждого из этих значений ESP, а также на 4 байта ниже; можно также добавить брейкпойнты на предполагаемые адреса локальных переменных. В результате Вы получите одну из трех ситуаций:

  1. Если после «сброса» стека программа попытается выполнить возврат из процедуры, она «споткнется» о брейкпойнт, стоящий по адресу ESP.
  2. Если программа попытается вызвать подпрограмму, и при этом поместит какое-либо значение (параметр или адрес возврата) на стек, команда, выполняющая запись данных в стек, вызовет срабатывание брейкпойнта по адресу ESP-4.
  3. Если брейкпойнт установлен на локальную переменную, исполнение программы будет прервано при первом же обращении к этой переменной.

Смысл всех этих действия заключается в том, чтобы как можно раньше остановить программу после «сброса» стека и узнать, что пытался скрыть автор защиты при помощи этого приема. Поскольку современные программы стеком пользуются достаточно активно, можно надеяться, что первое срабатывание нашей «ловушки» будет не слишком далеко отстоять от той точки, в которую был выполнен переход. Если выяснится, что адресов, на которых срабатывают ловушки, несколько, Вам нужно выбрать тот из них, обращение к которому выполняется раньше других – очевидно, что он наиболее близок к искомой точке.

 

Описанный метод также традиционно используется для определения OEP упакованных программ: заклинание «BPM ESP-4», которое следует набирать в SoftIce сразу после загрузки распаковываемой программы, так или иначе упоминается в большинстве статей, посвященных ручной распаковке кода. А вот смысл сего заклинания, увы, поясняется намного реже, и сейчас я попытаюсь исправить это недоразумение. Реализация абсолютного большинства навесных защит такова, что значение ESP в момент завершения работы защитного модуля и передачи управления на OEP в точности равно значению ESP сразу после загрузки запакованной программы. Причины этого лежат где-то в глубинах сознания разработчиков защит, поскольку уменьшение значения ESP перед исполнением программы на величину, кратную четырем, никаких отрицательных эффектов не вызывает, поскольку место под стек обычно резервируется с запасом (а вот увеличение значения ESP уже может быть чревато). Однако большинство упаковщиков считают, что значение ESP лучше передавать пользовательскому коду в неизменном виде, а потому свято блюдут принцип «сколько на стек положено – столько должно быть снято», причем окончательная коррекция стека скорее всего будет проведена непосредственно перед выполнением перехода на OEP. И если эта коррекция выполняется командами чтения данных из стека (POP, POPAD и т.п.), а не простой записью в ESP ранее сохраненного значения, Ваш брейкпойнт сработает в этот знаменательный момент. Таким образом, Вам останется лишь отсеять ложные срабатывания брейкпойнта, если таковые будут, и трассировать код до тех пор, пока управление не будет передано из распаковщика в основную программу.

 

Мой рассказ об аппаратных точках останова был бы неполон, если бы я не упомянул об одной специфической разновидности брейкпойнтов останавливающих исполнение программы при обращении к произвольной области памяти. Такие брейкпойнты в SoftIce под ОС Windows 9x создавались при помощи команды BPR (в современных версиях этого отладчика команда BPR, к сожалению, отсутствует, что особенно странно в свете того, что в арсенале OllyDebug такие точки останова имеются). Внешне использование таких брейкпойнтов ничем не отличается от работы с обычными аппаратными точками останова, если не считать возможности «накрыть» брейкпойнтами практически неограниченное количество участков памяти совершенно любого размера (что выгодно отличает данный тип брейкпойнтов от «обычных» аппаратных, которых может быть не более четырех). Гораздо больший интерес представляет знание о том, каким образом реализован этот тип брейкпойнтов, которое, возможно, пригодится Вам в будущем (к примеру, если Вы захотите написать собственный отладчик).

 

Как Вы знаете, процессоры линейки x86 в защищенном режиме (некоторые источники называют этот режим расширенным) содержат множество средств, облегчающих создание многозадачных программ и позволяющих защитить данные от некорректных операций над ними. Для нас особенно интересной представляется возможность изменять атрибуты защиты отдельных страниц памяти, то есть разрешать или запрещать определенный тип действий (запись, чтение, исполнение кода) над информацией, хранящейся на той или иной странице памяти. При этом любая попытка выполнить запрещенную операцию, к примеру, записать данные на страницу, для которой запись запрещена, вызовет исключительную ситуацию. Более того, чтобы ради этого нехитрого действа Вам не пришлось выбираться в нулевое кольцо защиты, в Windows API включены функции VirtualProtect и VirtualProtectEx, позволяющие изменять атрибуты страниц памяти (правда, флаг запрета на исполнение кода на платформе x86 бесполезен, но необходимости в таком запрете обычно и не возникает). Как видите, создать «область останова» не так уж трудно, основную сложность в реализации таких «областей останова» представляет обработка исключительных ситуаций, возникающих при обращении к данным из защищенной области. Основным препятствием в практической реализации является то, что обычно размеры «области останова» не выровнены на границы страниц и кратны размерам страницы. По этой причине возникает необходимость в написании достаточно изощренного кода, распознающего, к какой ячейке памяти произошло обращение и обеспечивающего корректное продолжение работы программы после ошибки нарушения прав доступа к странице (по сути требуется написать нечто среднее между простым дизассемблером и виртуальной машиной).

 

Уместно будет упомянуть, что некоторые защиты манипулируют атрибутами страниц, чтобы противодействовать отладке, memory patching’у и снятию дампов. Большинству патчеров и дамперов эти ухищрения глубоко безразличны, однако если Вы планируете заняться написанием собственных утилит для патчинга и/или снятия дампов (что я Вам настоятельно рекомендую), обязательно проверьте, как Ваши изделия будут реагировать на нестандартные атрибуты страниц. Поэтому если у Вас возникнут какие-либо проблемы при операциях с памятью чужого процесса, есть смысл поинтересоваться атрибутами страниц, с которыми Вы работаете (это можно сделать при помощи той же VirtualAllocEx).

 

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


Глава 11.

Трассировка во сне и наяву.

 

Трассировка – одна из основ, на которых держится крэкинг. О трассировке обычно говорят вскользь как о чем-то общеизвестном и само собой разумеющемся, но при этом имеющем нечеткие, плохо формализуемые правила. Читая предыдущие главы, Вы неоднократно встречали фразы «трассируем процедуру…», «трассируйте код до тех пор, пока…» и т.п., и при этом вряд ли задумывались о том, что нужно делать и как вообще трассируют код. Так что же такое отладка – наука это или искусство? Поставив себе целью разобраться в методах трассировки, мы прежде всего должны определиться, что такое трассировка и для чего она нужна. И лишь после того, как будут определены цели трассировки, возможно будет говорить о «технической» реализации методов, посредством которых эти цели могут быть достигнуты.

 

Прежде всего договоримся о терминологии. Само слово «трассировка» часто употребляется в различных смыслах – от синонима «отладки» до обозначения процесса пошагового исполнения программы (см. команды вроде «trace into» и «trace over» в некоторых отладчиках). Я не буду оригинален и введу еще одно значение слова «трассировка», которое и будет использоваться на протяжении данной главы: трассировка – это процесс определения «траектории» исполнения кода с некой заранее заданной точностью и наблюдение за изменениями во время исполнения этого кода. Вот такое определение – незамысловатое, но с замахом на глобальность. И, разумеется, без подробной расшифровки выглядящее достаточно туманно – как и любое другое достаточно широкое определение. Сам процесс трассировки может быть направлен на получение следующих знаний:

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

 

С пониманием того, что подразумевается под «заранее заданной точностью», я думаю, никаких сложностей у Вас не возникнет – эти слова означают всего лишь то, что при трассировке код анализируется исключительно лишь в той мере, которая нужна для решения практических задач. Если Вам необходимо полное понимание некоего алгоритма (то есть «заранее заданная точность» - максимальна), Вам придется анализировать код целиком; если же Вам нужен лишь ответ на вопрос «почему программа выдает сообщение «Неверный серийный номер» вместо поздравления с регистрацией», Вас скорее всего устроит экспресс-анализ четырех-пяти точек ветвления, находящихся «выше» процедуры, сообщающей об ошибке, а все, что происходит между этими точками, Вы вольны проигнорировать. Трассировка может заключаться в поиске двух-трех точек в программе и анализе десятка прилежащих к ним команд. Как такое возможно? Очень просто: для примера в качестве критерия «заранее заданной точности» мы берем факт чтения или записи данных в некую ячейку памяти и находим в листинге все явные обращения к соответствующему адресу. После этого мы можем смело сказать «при некоторых неизвестных условиях в данную ячейку могут быть записаны значения, равные нулю либо единице». Если речь идет о глобальном «флаге зарегистрированности», то весь дальнейший взлом сводятся к выяснению, какое из состояний означает зарегистрированность программы (автор программы мог проявить оригинальность, сделав код 1 признаком отсутствия лицензии) и исправлению одного бита в программе.

 

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

 

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

 

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’шной функции чтения командной строки, для Вас будет очевидно, что следующие за вызовом команды почти наверняка будут оперировать именно с текстом командной строки, а не с фазами Луны или курсом доллара. И вот тому пример:

 

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 в Паскале), да и много чем еще. Причем, если у функции есть параметры, они могут стать отличной подсказкой, позволяющей установить назначение этой функции (особенно хорошо этот прием работает с функциями, выполняющими преобразование строк). Для этого нужно под отладчиком исследовать, что именно передается в функцию и какой результат она возвращает. Нередко это даже оказывается проще, чем догадаться, что хранится в глобальной переменной.

 

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

 

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

 

Трассируя программы, Вам придется немало поводить пальцем по распечатке или экрану дисплея, взбираясь по дереву вызовов и переходов. А если к тому же Ваш дизассемблер «не умеет» выделять цветом команды переходов, Вы можете запросто пропустить что-нибудь важное. В общем, блуждание по бескрайним полям кода – дело, требующее внимания, сосредоточенности, и при этом достаточно утомительное. Однако если Вы решаете типовую задачу «как заставить программу выполнить некий код независимо от правильности исходных данных», Вам может помочь все та же карта всех возможных путей исполнения кода, построение которой я демонстрировал в самом начале статьи. Как только Вы составите такую карту, Вам останется лишь выполнить три несложных действия:

  1. Выбрать в программе и отметить на этой карте исходную точку. В качестве исходной точки лучше всего выбирать код, имеющий непосредственное отношение к исследуемой защите, который может быть легко идентифицирован и вызван из программы. Если речь идет о старых добрых серийных номерах, вводимых с клавиатуры, то лучше всего искать код чтения серийника из окна – этот код обычно легко «ловится» в отладчике и заведомо вызывается защитой (надо же ей как-то узнавать, с какими параметрами пользователь пытается зарегистрироваться).
  2.  Отметить конечную точку, путь к которой требуется найти (например, вызов MessageBoxA, сообщающий об успешной регистрации).
  3. Найти на карте путь (а лучше – все возможные пути) из начальной точки в конечную.

Как только такой путь будет найден, можете начинать соответствующим образом патчить код и выяснять, что из этого получится. Интересно, что выполнять второй пункт можно не только вручную (это чем-то похоже на детскую головоломку «найди путь в лабиринте»), но и в автоматизированном режиме – в этом случае потребуется решить типовую задачу из курса теории графов. Теоретически это позволяет поставить взлом защит, использующих только переключатель «зарегистрировано/не зарегистрировано» даже не на поток – на конвейер!

 

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

 

Попробовав трассировать в уме сколько-нибудь сложную процедуру, вырванную из середины программы, Вы заметите, что хотя возможные пути исполнения кода и известны, но сказать, какой путь изберет программа, если ее запустить, Вы не можете. И никто не может – поскольку при трассировке в уме Вам неизвестны исходные данные, которые и заставляют программу выбирать из всех возможных путей единственный актуальный. Именно здесь и пролегает граница между разглядыванием препарированного «мертвого» кода и вождения пальцем по распечатке и наблюдением за «живой» программой, обрабатывающей «настоящие» данные. Так что пришло время поговорить о методах трассировки программ «вживую».

 

Исторически первым способом трассировки «вживую» был многократный вызов в отладчике функции «исполнить текущую команду» с заходом в подрограммы или без такового. Проще говоря, программист сидел перед монитором и давил нужную кнопку, наблюдая, как бегает по коду курсор, и меняются значения регистров. Более продвинутые (или просто более ленивые) программисты смекнули, что для определения траектории важны не все команды, а лишь те, в которых программа делает выбор, в какую сторону ей «свернуть». А потому для определения траектории нужно наставить брейкпойнтов на команды условных вызовов и переходов, а все, что находится между этими командами, можно исполнять в автоматическом режиме. Интерес программиста был прост: наблюдая за траекторией «забега» программы, отследить момент, когда программа уклонится в неправильную сторону, а потом найти причину отклонения и исправить программу так, чтобы программа «бегала» по предназначенному ей пути. Обычно отслеживание траектории исполнения по «ключевым точкам» использовалось для получения ответов на вопросы «после какой точки программа начинает работать некорректно», а пошаговое исполнение – чтобы точно определить, какие именно команды формируют неверные данные. И методы эти за все годы, прошедшие со времен их появления, ничуть не утратили актуальность и широко применяются в практически неизменном виде.

 

Технический прогресс не стоял на месте: расставлять брейкпойнты и гонять по ним программу вручную было неудобно, и программистам не могла не прийти в голову мысль «а почему бы не усовершенствовать отладчик таки образом, чтобы собственно трассировка выполнялась в автоматическом режиме». Прообразом современных средств трассировки был режим «анимации» (то есть замедленного исполнения) программы – при некотором навыке в мелькании содержимого регистров и переменных можно было попытаться отловить полезную информацию и «притормозить» программу в нужный момент. Впрочем, пользы от этого режима в те времена было немного – изучать в режиме анимации сложные программы было неудобно, а анимировать программы, работающие с графикой - и вовсе невозможно (в те времена отладчики функционировали исключительно в текстовом режиме). Однако в некоторых современных отладчиках режим анимации все-таки сохранился, и, надо отметить, толку от него куда больше, чем во времена DOS. Значительно возросшее быстродействие современной техники превратило анимацию из слайд-шоу для особо терпеливых, сопровождавшегося лихорадочным мерцанием экрана, в весьма динамичное действо, наблюдение за которым доставляет лишь удовольствие. Некоторые люди считают, что исполнение кода в режиме анимации никакой практической пользы не приносит, но я придерживаюсь несколько иного мнения: в этом режиме хорошо заметны длинные циклы, а также циклы со счетчиком, особенно если счетчик расположен в одном из регистров. Начинающим, думаю, интересно будет «вживую» понаблюдать работу распаковщика исполняемых программ – лучше один раз увидеть работу несложной процедуры распаковки, чем сто раз прочитать «книжное» описание этого процесса. Для просмотра этого шоу лучше всего взять небольшую программу, сжатую UPX’ом, поскольку «навесная» процедура распаковки UPX – одна из самых простых, и даже если у Вас не получится полностью разобраться в ней при помощи отладчика/дизассемблера, все сложные моменты можно разъяснить по исходным текстам, находящимся в свободном доступе.

 

Читая эту главу, Вы могли заметить, что многие из описанных методов, несмотря на всю их внешнюю простоту, довольно неудобны для практического применения, поскольку требуют от пользователя отличной памяти и внимательности, а также способностей к рекурсивному «чтению» кода. Попробуйте «пробежаться» отладчиком по достаточно сложной процедуре, при этом строя траекторию исполнения кода в уме. Если у Вас получилось – значит, либо процедура оказалась не очень сложной, либо большинству людей остается лишь позавидовать Вашей памяти. Так или иначе, но программисты пришли к идее автоматической трассировки, то есть пошагового исполнения программы с одновременным «запоминанием» всех сделанных шагов. Основным препятствием на пути к осуществлению этой идеи долгое время был сравнительно небольшой объем оперативной памяти старых ЭВМ и недостаточная мощность процессора. Даже такой старый процессор, как 8086, способен был выполнять десятки и сотни тысяч команд в секунду – представьте, какой объем памяти потребовался бы, чтобы запомнить всего лишь последовательность адресов исполненных команд, не говоря уже о состоянии регистров. Кроме того, на одну команду, выполненную отлаживаемой программой в режиме трассировки, приходятся десятки и сотни команд, выполненных отладчиком – и отсюда возникает заметное падение производительности трассируемой программы. В общем, до некоторого времени реализация такого способа трассировки машинного кода была практически нереальна. Однако когда объем памяти компьютеров начал измеряться мегабайтами, воплощение этой идеи наконец стало возможным. Режим трассировки появился сначала в SoftIce, а затем и в OllyDebug, причем по возможностям трассировки и связанных с ней функций OllyDebug определенно превзошел все прочие известные автору отладчики.

 

Прежде всего следует отметить, что OllyDebug, в отличие от SoftIce, запоминает трассировочную информацию более «интеллектуально» - то есть сохраняет не только список адресов команд, но и модификации регистров. К сожалению, хранить информацию обо всех изменениях в адресном пространстве процесса OllyDebug не может (это потребовало бы совершенно невообразимого объема ОЗУ), но если обращение к переменной производится по указателю на нее, отладчик вполне способен запомнить значение регистра-указателя. Такое поведение отладчика облегчает задачу отслеживания состояний регистров: если Вас интересует, в какой момент том или ином регистре «появилось» некое число, Вы можете решить эту задачу простым поиском этого числа в текстовом файле. Да-да, именно в текстовом файле – OllyDebug обладает совершенно уникальной на сегодняшний день возможностью сохранять практически любые промежуточные данные из отладчика на жесткий диск, и в число таких данных входит отчет об исполнении программы в режиме трассировки. Вы можете безо всяких ухищрений сохранить список всех исполненных команд, их адресов, а также всю дополнительную информацию об изменениях в регистрах. Пример такого отчета Вы можете увидеть ниже:

 

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’ового происхождения и навыки в программировании способны творить с «сырыми» листингами такие чудеса, какие традиционным дизассемблерам и не снились – от простого просмотра с подсветкой синтаксиса до форматирования «лесенкой» текстов на ассемблере. А поскольку наш отчет о трассировке как раз имеет текстовый формат и является, по сути, специфической формой дизассемблерного листинга, к нему вполне применимы все изложенные выше соображения о работе с текстом.

 

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

 

Увы, в каждой бочке меда есть своя ложка дегтя, а в нашем случае – даже не одна. При пошаговой отладке (а, стало быть, и в процессе трассировки – тоже) накладные расходы на исполнение одной команды в десятки и сотни раз превышают собственно время исполнения команды, из-за чего скорость исполнения кода в режиме трассировки падает катастрофически. О расходах памяти на хранение информации о порядке исполнения команд я уже упоминал, однако это еще не все: если внутри отладчика эта информация подвергается упаковке для более компактного хранения, то когда Вы сохраняете эту информацию на диск в виде текста, результирующий файл может получиться просто огромным. И он будет тем огромнее, чем большее количество команд было исполнено в режиме трассировки. Так что в ближайшем будущем нам, увы, не светит возможность запустив программу под отладчиком в режиме трассировки, беспрепятственно снимать с нее «жизненные показатели», выискивая подозрительные значения регистров и «нехорошие» команды переходов. Трассировка – это метод, пригодный для анализа сравнительно небольших кусков кода, чего, впрочем, обычно более чем достаточно. Также есть принципиальные проблемы с трассировкой программ, активно работающих с «железом», многопоточных программ и с софтом, использующим коммуникацию между процессами – все эти группы ПО, даже не содержащие защит, во время трассировки могут вести себя очень капризно.


Глава 12.

Патчить или не патчить?

 

Быть или не быть? Вот в чем вопрос.

У. Шекспир

 

Собственно, вопрос, вынесенный в заглавие этой главы, существует разве что в умах ревнителей идейной чистоты крэкинга, с недоверием относящихся к самой идее модификации исполняемого кода. Для всех остальных ответ очевиден – патчить надо и патчить надо много, поскольку модификация исполняемого кода – самый прямой, простой и быстрый путь заставить код делать то, чего Вы от него хотите. Вряд ли вообще возможно найти хотя бы одного крэкера, который не изготовил бы ни одного патча, не дописал бы в программу десяток-другой байт, не экспериментировал с «отклонением» системных вызовов на свои собственные функции. Хотя обычно патчинг представляют как исправление пары-тройки байт (обычно – в командах условного перехода), на самом деле эта техника применяется не только для банального «бит-хака», но и в таких областях, как перехват системных вызовов, внедрение собственного кода в чужие программы, дописывание недостающей функциональности в программы, а также для многих других не менее интересных вещей. За время чтения предыдущих глав Вы наверняка уже приобрели навыки, необходимые для успешного исправления опкода 75 на опкод EB, научились выкидывать из программ целые процедуры, не порушив при этом стек, и разобрались в основах устройства той операционной системы, под которой Вы планируете развернуть свою крэкерскую деятельность. Стало быть, пришло время рассказа о тонкостях и нетривиальных подходах к модификации программного кода, которая есть альфа и омега всего крэкинга.

 

Исправлять переходы или содержимое констант – дело нехитрое, но рано или поздно перед Вами встанет в полный рост жестокая необходимость вклинить пару-тройку собственных команд туда, где для этих команд место не предусмотрено, и вставить так, чтобы ничего при этом не испортить. Решение достаточно очевидно – поместить нужный код в свободную область памяти, при помощи команд условного перехода сделать «отвод» в эту область, а потом вернуть управление обратно. Но вот где эту свободную область взять? Смею Вас заверить, таких свободных областей в исполняемых файлах обычно более чем достаточно и искать их долго не придется. В зависимости от требуемого объема памяти Вы можете воспользоваться одной из следующих пяти стратегий:

 

  1. Использование пространства между процедурами или массивами. Во многих компиляторах для повышения быстродействия по умолчанию включены опции выравнивания начала процедур и массивов. К примеру, если процедуры выровнены по границам 16-байтных областей, Вы можете обнаружить до 15 байт свободного места перед или после каждой процедуры. Найти такие промежутки несложно – они инициализированы последовательностью одинаковых байт, обычно с кодом 0 или 90h (этому коду соответствует команда nop), реже - 0ССh (команда int3) или 0FFh. В такую область вполне возможно вписать пару-тройку команд для загрузки данных в регистры или изменения значения переменной. Поскольку процедуры встречаются достаточно часто, передать управление на такой блок и затем вернуться обратно, скорее всего, получится при помощи двухбайтного варианта команды jmp, что сведет к минимуму расход памяти на переходы туда-обратно. Кроме того, при помощи все тех же двухбайтных переходов можно связать воедино несколько таких свободных кусков и таким образом увеличить количество команд. Недостатки этого метода: без ухищрений невозможно вписать в программу достаточно длинную последовательность команд; связывать вручную свободные куски довольно неудобно; нет никакой гарантии, что поблизости окажется достаточно длинный кусок свободной памяти; при включенной оптимизации по размеру компилятор может размещать процедуры вплотную, что сделает данный метод неприменимым. Кроме того, практически невозможен вызов функции, не импортируемой явным образом: Вам просто надоест вручную сооружать «обвязку» для вызова GetProcAddress, параллельно размазывая ее по щелям между процедурами.
  2. Использование места, высвободившегося после «вывода из обращения» защитных процедур или ненужных тестовых строк. В процессе взлома в программах нередко обнаруживаются «лишние» защитные процедуры, которые крэкеру приходится просто отключать, а также всевозможные надписи, предлагающие обменять некоторую сумму денежных единиц на серийный номер или ключевой файл. При необходимости память, занимаемая этими процедурами и надписями, может быть использована для размещения небольших блоков кода. Возможен и другой вариант использования этого метода – исключить из программы какую-нибудь функцию, которая Вам заведомо не понадобится, а на занимаемое ей место поместить собственный код. Кроме того, есть смысл исследовать подфункции вызываемой функции: некоторые из них могут использоваться исключительно внутри удаляемой функции, и потому тоже могут послужить источником дополнительных байтов. Недостатки метода: не всегда возможно получить достаточный объем памяти; нужна крайняя аккуратность при переписывании команд, содержащих явное указание адреса в памяти. Метод в принципе неприменим, если Вы используете крэкинг «в мирных целях», то есть Ваши задачи предполагают не выламывание из программы защитных процедур, а только лишь усовершенствование программы за счет добавления в нее собственного кода.
  3. Размещение кода в неиспользуемых областях в концах секций. Программисты на ассемблере под Win32 наверняка хорошо знакомы со следующим эффектом: при добавлении в программу новых строк ее размер до поры-до времени не меняется, а потом вдруг скачкообразно увеличивается на полкилобайта, а то и больше (конкретное число зависит от настроек линковщика). Причина этого эффекта в том, что код и данные в программах под Win32 (да и не только под Win32) расположены внутри исполняемого файла в секциях, и размеры этих секций должны быть кратны определенному (и довольно большому) числу байт. Нередки ситуации, когда реальный размер кода программы или ее инициализированных данных на несколько десятков, а то и сотен байт меньше, чем размер выделенной для этого кода секции, и кусок памяти в «хвосте» секции ничем полезным не занят. А если в программе есть «лишние» байты, то почему бы не использовать их для своих целей? Недостатки метода: объем неиспользованного пространства в конце секции совершенно непредсказуем и может колебаться от нуля до нескольких килобайт; следует быть крайне осторожным при записи данных в конец секции инициализированных данных, чтобы случайно не испортить эти самые данные: к примеру, свободное место и длинный массив байтов, инициализированный нулями, внешне выглядят совершенно одинаково.
  4. Расширение существующих секций или создание новых. При таком подходе в принципе невозможно случайно разрушить полезные данные – весь Ваш код будет размещаться в областях, которых в непатченой программе даже не существовало. Более того, таким способом можно внедрить собственный код даже в упакованную программу, если распаковщик не содержит специальных средств для контроля целостности исходного файла (в этом случае лучше создать отдельную секцию и размещать свой код в ней). Размер внедряемого кода при использовании этого метода ограничен разве что здравым смыслом и объемом памяти ЭВМ. Основной недостаток заключается в том, что создание или расширение секций предполагает модификацию PE-заголовка и изменение размеров файла, из-за чего антивирусные программы могут не слишком благосклонно отнестись к таким переменам. Кроме того, поскольку размер файла изменяется за счет вставок в середину, возможны проблемы с созданием исполняемых файлов, выполняющих автоматический патчинг (такие файлы в просторечии называются «крэками»). Большинство утилит, создающих такие файлы, ограничивается побайтным сравнением с оригиналом и потому «не переваривают» вставку даже одного-единственного байта в середину программы. Кроме того, если Вам потребуется вызвать функцию API, которая отсутствует в таблице импорта, Вам придется либо править эту таблицу, либо получать адрес функции при помощи вызова GetProcAddress, что не слишком удобно. Этот недостаток в полной свойственен и трем предыдущим стратегиям патчинга, причем в еще большей мере – по причине ограниченного объема памяти, который эти стратегии позволяют получить. Кроме того, операции по расширению и вставке секций технически очень сложно выполнить в памяти над загруженной программой, поскольку это требует очень глубоких знаний системы и весьма значительных трудозатрат, совершенно не адекватных получаемому результату.
  5. Подгрузка кода, размещенного во внешних модулях. Это наименее «жесткий» по отношению к модифицируемой программе метод внедрения кода. Идея метода заключается в том, чтобы вынести весь добавляемый в программу код во внешний модуль (если речь идет про Windows – то в DLL), а потом «попросить» программу загружать этот внешний модуль вместо одной из библиотек, например, исправив один-единственный байт в имени загружаемой DLL. «Подменная» DLL с нашим кодом вклинивается между программой и «настоящей» библиотекой, причем в функции инициализации DLL можно разместить что-нибудь полезное, например, код патчинга основного процесса. Более подробно методы внедрения модулей мы рассмотрим чуть позже, а пока отметим, что при использовании данного метода Вы получаете наиболее широкий выбор инструментария для написания внедряемого кода: от шестнадцатеричного редактора до самых современных RAD-средств (в отличие от предыдущих четырех стратегий, где Вашим основным инструментом будет ассемблер, встроенный внутрь редактора или отладчика). Главный недостаток заключается в необходимости держать этот самый внешний модуль рядом с программой, что не всегда удобно.

 

Рассказывая об использовании пространства, занимаемого «ненужными» процедурами, для размещения своего кода, я упомянул о проблеме, связанной с затиранием команд условных переходов, вызова подпрограмм и чтения содержимого фиксированных адресов. Изучая ассемблер, Вы наверняка заметили, что одним и те же мнемоникам «call» и «jmp» соответствует множество разных опкодов. Также Вы могли заметить, что в зависимости от опкода может варьироваться не только длина операнда, но и сам способ указания адреса, на который будет выполнен переход: адрес может указываться либо явным образом, либо в виде смещения относительно начала следующей команды. В современных версиях Windows исполняемые файлы всегда загружаются с адреса, указанного в заголовке исполняемого файла. Однако это правило распространяется только на исполняемые файлы, а вот DLL должны уметь загружаться с любого адреса (традиционно свойство программ или библиотек загружаться с любого адреса называется «перемещаемость»). Если учесть, что Windows – не единственная, а лишь одна из множества ОС с разными моделями памяти и механизмами загрузки приложений и библиотек, а задача обеспечения перемещаемости программных модулей возникает не так уж редко, реализация относительных переходов и вызовов «в железе» сильно облегчило жизнь разработчикам-первопроходцам.

 

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

 

Но все-таки, как решается проблема использования абсолютных адресов в перемещаемых программах? Наиболее распространенный подход - коррекция всех абсолютных адресов переходов и переменных во время загрузки. Но чтобы откорректировать абсолютные адреса, где-то должен храниться список всех точек программы, где используется абсолютная адресация. Для этого в исполняемый файл включается специальная таблица перемещаемости (она обычно называется relocation table), в которой указываются смещения всех точек программы, которые нужно откорректировать. Сам процесс пересчета адресов осуществляется весьма просто: вычисляется разность между адресом, начиная с которого был загружен программный модуль и «желательным» адресом загрузки который был в заголовок модуля прописан линковщиком; затем полученная разность прибавляется к содержимому, находящемуся по смещениям, перечисленным в таблице перемещаемости.

 

Этот пересчет адресов можно рассматривать как акт патчинга со стороны операционной системы, осуществляемый во время загрузки. Теперь представьте, что произойдет, если Вы, ни о чем не подозревая, впишете вместо команды, обращающейся к какой-либо ячейке памяти, свой код. Загрузчику в общем-то без разницы, какие именно байты он исправляет – поэтому он заглянет в таблицу перемещаемости, выберет оттуда адреса ячеек, которые нужно исправить, а потом честно попытается откорректировать содержимое этих ячеек. Однако мы наполнили эти ячейки совершенно иным содержанием, и потому там лежит не адрес, который надо пересчитать, а нечто совершенно иное – написанные нами коды команд. Если алгоритмы работы загрузчика предполагают проверку корректности полученных при пересчете адресов, Вы можете получить сообщение об ошибке уже на этапе пересчета, если не предполагает – программа почти наверняка «рухнет» при попытке исполнить такой дважды исправленный кусок кода. Выходов – два: либо перетрясти таблицу перемещаемости и исключить из нее ненужные нам ссылки, либо при патчинге не затрагивать последовательности байт, похожие на абсолютные адреса в памяти, если таковые встретятся на Вашем пути. Надо отметить, что практически все программные «полуфабрикаты» - библиотеки, объектные файлы, предварительно откомпилированные модули Delphi (то есть файлы с расширением .dcu), компоненты – изначально рассчитаны на внедрение в неизвестное место будущей программы и потому являются перемещаемыми.

 

Другим интересным вопросом, непосредственно связанным с патчингом, является вызов функций API из внедряемого в программу кода. Как я уже упоминал в девятой главе, при статической загрузке библиотек (то есть когда библиотеки подгружаются уже в процессе загрузки программы на основе информации из таблицы импорта) вызов внешних функций осуществляется не прямым переходом по нужному адресу, а через «переходники», которые и передают управление на библиотечные функции. Понятное дело, что если такие «переходники» есть, то ими можно воспользоваться. Но как быть, если таких «переходников» нет (хотя заведомо известно, что нужная функция все-таки вызывается) или использовать нельзя их по каким-либо причинам? Есть как минимум два пути, позволяющих вызвать функции API даже в таких стесненных условиях. Обычный программистский подход к решению проблемы – вызвать функцию LoadLibrary , а вслед за ней – GetProcAddress и получить адрес искомой функции, после чего можно смело помещать параметры на стек и затем выполнить call [eax]. Для этого, разумеется, нужно, чтобы программа импортировала функции LoadLibrary и GetProcAddress, но тут обычно проблем не возникает: подавляющее большинство программ эти функции импортирует, а те, которые не импортируют, можно заставить это делать редактированием таблицы импорта.

 

Однако я предпочитаю использовать другой способ, как мне кажется, более удобный и безопасный. Основа метода заключается в том, чтобы вызывать функции не напрямую, а «одалживать» нужные вызовы у исследуемой программы. Если программа импортирует функцию MyFunc статически, это почти наверняка означает, что в коде программы есть команда call MyFunc. А если в программе есть команда call MyFunc, значит, когда мы хотим вызвать MyFunc, нам нужно всего лишь прочитать четыре байта (которые представляют собой адрес нашей функции) в регистр eax, поместить на стек параметры и выполнить вызов. На практике эта операция выглядит примерно так:

 

FAddr: call ExitProcess ; Нужная нам функция

 

OurPatch:

mov eax, dword ptr [FAddr+2]   ; Читаем из памяти адрес функции

push 0 ; Помещаем на стек параметр вызова

call [eax]          ; Вызываем «одалживаемую» функцию

 

Однако вызовами статически импортированных функций API возможности метода «функций взаймы» отнюдь не ограничиваются. «Заимствовать» из исследуемой программы можно совершенно любые функции – от элементарного сравнения строк до функций извлечения файлов из архива неизвестного формата или генерации серийных номеров (изредка встречаются программы с такой ошибкой в защите). Единственное, что для этого необходимо – выяснить тип передаваемых функции параметров и используемое соглашение вызова. Однако нужно понимать, что заимствование функций из программы или из несистемных DLL не вполне известного назначения – операция более опасная, чем использование импортированных функций документированных API. Дело в том, что работа функций API не привязана ни к каким переменным и функциям внутри программы (если, конечно, не считать callback-функции), а вот к функциям программы это в общем случае не относится. Их работа может зависеть от состояния локальных или глобальных переменных, а также других объектов, созданных в процессе работы программы; более того – сами программные вызовы тоже вполне могут изменять состояние переменных, критически важных для работы программы. Поэтому если какой-либо объект в момент вызова окажется в «неправильном» состоянии, результат вызова будет совершенно непредсказуем. Точно таким же образом никто не может гарантировать, что «несвоевременный» вызов не нарушит функционирование всей программы. Хотя, с другой стороны, встроенные процедуры, как правило, достаточно корректно обращаются с переменными и объектами, созданными программой. Так что если Вам вдруг понадобится форсировать загрузку какого-либо плагина, лучше попытаться это сделать это «родными» для программы средствами, и лишь если это у Вас не получится, прибегнуть к средствам API. В любом случае, при заимствовании из основной программы функций в качестве постоянного решения следует быть очень осторожным, хотя сам по себе этот метод нередко бывает полезен.

 

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

  • Предварительный анализ параметров, передаваемых в функцию (например, для того, чтобы предотвратить выполнение нежелательных вызовов)
  • Коррекция результата, возвращаемого функцией
  • Выполнение каких-либо действий помимо тех, которые предполагались в программе (например, усовершенствование процедуры, создающей nag screen таким образом, чтобы это окно после исполнения функции сразу же закрывалось)
  • Ведение и сохранение журнала вызовов функций, снятие статистики по выполнению тех или иных действий и частоте появления на стеке тех или иных значений

 

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

  1. Загрузить процесс в память, не запуская его (например, создав процесс с флагом CREATE_SUSPENDED, если речь идет о Win32).
  2. Внедрить «шпионский» модуль в адресное пространство исследуемого процесса, например при помощи хуков или функции CreateRemoteThread (только под Windwos линейки NT).
  3. Пропатчить в памяти процесса «переходники» к функциям API таким образом, чтобы они указывали на обработчики соответствующих функций внутри «шпионского» модуля.
  4. Аналогичным образом перебросить переходник к функции GetProcAddress на собственный обработчик. Эта операция очень важна, поскольку только таким образом можно перехватить вызовы функций, адреса которых программа динамически запрашивает в процессе работы.
  5. «Разморозить» процесс, после чего программа начнет исполняться.

 

Однако патчинг и перехват системных вызовов – отнюдь не прерогатива одних лишь крэкеров. Разработчики защит, хотя и с изрядным опозданием, тоже взяли на вооружение идею перенаправления системных вызовов на собственный код. Как я уже упоминал, сравнительно недавно в большом количестве появились навесные защиты, которые позволяют упрятывать любую программу и все необходимые для ее работы файлы в упакованном и зашифрованном виде внутрь одного-единственного EXE. При этом сама программа может обращаться к своим файлам как средствами Win32 API, так и при помощи высокоуровневых функций (которые по сути являются «обертками» для все тех же системных вызовов). О том, каким образом работают такие защиты, Вы уже наверняка догадались: в исполняемый файл дописывается секция, где хранится код, обрабатывающий вызовы системных функций для работы с файлами. Необходимые для работы программы файлы упаковываются и цепляются в «хвост» программы, а таблица импорта дорабатывается таким образом, чтобы вызовы функций работы с файлами перенаправлялись на обработчики, находящиеся во внедренной секции. Когда защищенная программа попытается обратиться к файлу, задача обработчиков заключается в том, чтобы проверить, к какому именно файлу происходит обращение, и либо передать это обращение операционной системе в неизменном виде (если программа обращается к файлу вне «хранилища»), либо имитировать работу системного вызова, но в действительности считывание данных осуществлять из упакованного «хранилища». Однако ирония судьбы заключается в том, что метод взлома полностью аналогичен методу защиты. Действительно, если разработчик «отклонил» вызовы функций работы с файлами на собственный код, то и почему бы и крэкеру не проделать ту же самую операцию? То есть вклинить между программой и обработчиком свой собственный «обработчик обработчика», который будет сбрасывать все «спрятанные» файлы в надежное место. Основную проблему составляет поиск входных и выходных точек этих обработчиков, но здесь могут помочь «особые приметы», которые способны выдать чужеродный код:

  1. Вклинить «левый» код в середину практически невозможно, поэтому остаются варианты с расширением секций (тогда нужный код окажется перед началом или в самом конце программы), созданием новой секции (что иногда заметно по «странным» адресам, заметно отличающимся от адресов основной программы) или динамическим выделением куска памяти и размещением там обработчика (самый сложный для реализации способ – а потому самый маловероятный).
  2. Если защищенная программа читает упакованные файлы «по требованию», каждый вызов функций чтения файлов внутри программы будет сопровождаться обращениями программы к «хвосту» своего EXE-файла. Если программа читает все упакованные файлы в память сразу, такое обращение будет выполнено во время запуска программы. В обеих случаях эти вызовы вполне «уязвимы» для точек останова.
  3. Наиболее уязвимы те вызовы, которые передаются в систему в неизменном виде (т.е. обращения к файлам, не находящимся внутри упакованного «хранилища») – установка брейкпойнтов на функции работы с файлами позволяет найти их без особого труда, после чего можно добраться и до точек входа и выхода в защитные процедуры-обработчики системных вызовов.
  4. В памяти почти наверняка будут застревать куски спрятанных файлов, и если Вы знаете, какая информация в этих файлах может оказаться, Вы можете попытаться найти эти куски памяти и попытаться выяснить, каким образом эти куски там появляются (например, при помощи поиска ссылок на начала таких кусков или установкой брейкпойнтов на запись в память).

 

Все эти внедрения в чужой процесс, поиски переходников и прочее вполне способны повергнуть в шок начинающего. Это не страшно – даже если Вы никогда не напишете собственный API-шпион, знания о том, как они работают, вполне могут Вам пригодиться в дальнейшем. Как Вы уже догадались, написание приложений, перехватывающих системные вызовы – занятие далеко не самое простое и требующее определенных знаний и навыков. С другой стороны, большинство API-шпионов ограничиваются лишь ведением журнала системных вызовов и не позволяют активно вмешиваться в работу программы. Для начинающего крэкера, у которого есть желание перехватить какой-нибудь системный вызов, но нет опыта в системном программировании, это звучит как приговор. Но ведь так хочется иногда не только подсмотреть, откуда взялись те или иные параметры, но еще и поменять их «на лету», если они Вам чем-то не понравились…

 

Я уже говорил, из любой безвыходной ситуации существует как минимум два выхода. Этот афоризм верен и в нашем случае, однако от Вас все же потребуются определенные навыки в программировании. Итак, наша задача – вклинить свой код между системной DLL и программой, которая ее вызывает. Одна из первых идей, которые приходят в голову, заключается в том, чтобы вместо «родной» DLL подсунуть свою собственную, которая содержала бы функции с такими же именами, что и «настоящая». Эта библиотека должна помимо вызова «родных» функций из оригинальной библиотеки выполнять еще и те операции, которые Вы в нее заложите. А уж в собственной DLL Вы вольны запрограммировать все, что угодно – от сбора статистики вызовов до анализа и подмены параметров функций. Такой подход к перехвату вызовов из DLL, основанный на подмене оригинальных библиотек, называется DLL wrapping’ом. Чисто технически создание подменной DLL выполняется следующим образом:

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

2. Изготовить на основе этого списка болванку будущей подменной DLL. В простейшем случае эта болванка будет выглядеть как набор одинаковых кусков. Поскольку болванка имеет регулярную структуру, ее можно сгенерировать автоматически. Для простейшего случая, когда нужно перехватить одну лишь функцию MessageBoxA из user32.dll, наша подменная библиотека, написанная на MASM, будет выглядеть примерно так:

 

PUBLIC MessageBoxA

 

.data:

IsLoaded dd FALSE

aMessageBoxA dd 0

huser32 dd 0

u32 db "e:\Windows\System32\user32.dll",0

nMessageBoxA db "MessageBoxA",0

 

.code:

 

DllEntry proc hInstance:HINSTANCE, reason:DWORD, reserved1:DWORD

   mov eax,TRUE

   ret

DllEntry Endp

 

CheckAndImport proc

               

           .if !IsLoaded

                          mov IsLoaded,TRUE

                          invoke LoadLibrary,ADDR u32

                          mov huser32,eax

                          invoke GetProcAddress,huser32,ADDR nMessageBoxA

                          mov aMessageBoxA,eax

           .endif

           ret

 

CheckAndImport endp

 

MessageBoxA::

; Адрес метки MessageBoxA и прочих аналогичных нужно включить в таблицу экспорта

; при помощи директив

; LIBRARY user32

; EXPORTS MessageBoxA,

; помещенных в def-файл

 

           invoke CheckAndImport

               

; Здесь Вы можете разместить код, исполняемый перед вызовом перехватываемой функции

           ; Например, поменять местами заголовок и текст сообщения,

; как это сделано ниже

mov eax,[esp+8]

xchg [esp+12],eax

xchg [esp+8],eax

 

           mov eax, MessageBoxA_

           jmp eax

 

Процедура CheckAndImport выполняет важную функцию - во время первого вызова любой из функций она подгружает настоящую user32.dll, получает адрес функции MessageBoxA и помещает этот адрес в переменную аMessageBoxA. Если Вам известно, какая из функций DLL будет вызвана первой, можно сократить код, разместив вызов функции CheckAndImport только в этой функции. Вообще, внешне этот код выглядит довольно тяжеловесно – экспорт меток, специфический способ обращения к параметрам вызова через смещение относительно значения ESP, динамическая загрузка библиотек – и тут же отсутствие возможности выполнить собственный код после вызова MessageBox. Причина тяжеловесности проста: этот код представляет собой максимально упрощенную адаптацию реально использовавшейся мной подменной DLL. Что же касается мнимой невозможности выполнить собственный код после вызова MessageBoxA, то эту проблему проще всего решить при помощи подмены лежащего на стеке адреса возврата: нужно подправить его таким образом, чтобы после выполнения команды jmp <имя_функции> возврат выполнялся не в программу, а на следующую за jmp команду. Разумеется, старый адрес возврата тоже надо где-то сохранять - он Вам понадобится, чтобы вернуть управление программе. Если Вам известно количество параметров, помещаемых на стек при вызове, и их размерность, никаких сложностей с подменой адреса возврата не возникнет. Но вот если в процедуру передается неизвестное заранее количество параметров (такое, в частности, возможно в программах, написанных на C), общего решения этой проблемы не существует, так что Вам придется действовать по обстоятельствам и изобретать метод определения количества параметров на стеке самостоятельно.

 

Но почему я вдруг отклонился от темы этой главы и уделил столько времени технике подмены DLL? Причина в том, что DLL wrapping нередко используется совместно с патчингом исполняемого файла. Внимательно посмотрев на текст подменной DLL, Вы увидите, что загрузка user32.dll производится с явным указанием расположения этой библиотеки. Однако даже если скомпилировать эту DLL с именем «user32.dll» и положить рядом с подопытной программой, единственным результатом которой является вывод MessageBox’а с неким сообщением, Вы все равно не добьетесь желаемого результата – программа вызовет эту функцию прямиком из системной библиотеки, проигнорировав Вашу приманку. Что же делать?

 

Самый простой и доступный выход заключается в том, чтобы переименовать нашу подменную библиотеку в user33.dll, а потом забраться в исполняемый файл шестнадцатеричным редактором и там поменять имя импортируемой библиотеки аналогичным же образом. Теперь наша программа будет вместо стандартной библиотеки Windows подгружать нашу user33.dll и вызывать MessageBoxA именно из нее. Теоретически можно было поступить и несколько иначе – переименовать системную библиотеку user32.dll в user33.dll, а на место user32.dll положить нашу подменную библиотеку (разумеется, исправив путь к «настоящей» динамически загружаемой библиотеке), но на практике проделывать фокусы с заменой системных библиотек крайне нежелательно. Хотя если речь идет не о системной библиотеке, то действительно можно обойтись одним лишь переименованием «настоящей» DLL и помещением на ее место «поддельной».

 

Изучая перехват системных вызовов, мы вторглись в высокие сферы патчинга процессов, и теперь настало время всерьез углубиться эту тему. Хотя «в общее пользование» всевозможные launcher’ы, правящие файлы прямо в памяти, попадают сравнительно редко, для домашнего пользования техники патчинга процессов более чем актуальны. Если у Вас есть сжатая упаковщиком EXE программа, но нет желания заниматься ее распаковкой либо Вы просто хотите поэкспериментировать над программой, проверяющей контрольную сумму собственного файла, патчинг кода «на лету» - это весьма эффективный способ добиться желаемого с минимальными усилиями.

 

Модификация кода программы в памяти может выполняться двумя путями: записью данных в адресное пространство процесса извне или же внедрением в адресное пространство процесса собственного кода, который уже будет работать внутри подопытного процесса и выполнять необходимые действия по патчингу. В подавляющем большинстве случаев вторжение в чужое адресное пространство извне осуществляется при помощи последовательности вызовов VirtualProtectEx-WriteProcessMemory, хотя возможны и более сложные варианты с выходом в нулевое кольцо (правда, после того, как линейка Windows 9x начала утрачивать актуальность, простые в осуществлении способы выхода в Ring0 остались не у дел). Для того, чтобы добраться до процесса, Вам почти наверняка потребуется его дескриптор (он же «хэндл» - «handle»). Если запуск программы и патчинг ее процесса выполняется одной и той же программой, особых сложностей не возникает: функция CreateProcess[Ex] возвращает хэндл порождаемого ей процесса. Но это не единственный подход к добыче желанного дескриптора – до него также можно добраться через получение идентификатора процесса, породившего окно (при помощи функции GetWindowThreadProcessId) либо через анализ «снимка» всех процессов, полученных при помощи функций CreateToolhelp32Snapshot, Process32First и Process32Next.

 

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

 

Выше мы рассматривали всевозможные приемы, позволяющие, образно говоря, залезать в чужое адресное пространство через окно. Но иногда разработчики программ предоставляют бесценную возможность попасть внутрь их детища с «парадного входа», не таясь. И возможность эта - «родимое пятно» практически всех программ, использующих широко распространенный механизм расширения возможностей программы за счет плагинов. Плагин обычно представляет собой самую обычную DLL, построенную в соответствии с теми или иными стандартами, задаваемыми разработчиком программы. Однако что именно делает плагин, программу обычно не волнует – он может как выполнять штатные функции по обработке изображения, звука или чего-нибудь еще, так и быть «троянским конем», созданным с одной лишь целью – в момент первого же обращения к нему исправить внутри программы-«хозяина» несколько байт. Поскольку Ваш плагин будет «жить» внутри подопытной программы, для записи данных в адресное пространство Вам не нужны будут никакие ухищрения, достаточно всего лишь поместить правильные значения по известным адресам при помощи самой обычной команды mov. Однако как добиться, чтобы Ваш код выполнился как можно быстрее? Точные сведения могут быть получены лишь через эксперименты, но приблизительное направление поиска следующее: многие программы после загрузки плагина первым делом «спрашивают» свежезагруженный модуль о его названии, назначении, поддерживаемых функциях и прочих подобных параметрах. А поскольку поиск и загрузка плагинов нередко выполняется сразу после старта программы (а если очень повезет – и до срабатывания защитных механизмов или параллельно с их работой), у Вас есть хорошие шансы пропатчить программу уже на первых стадиях ее работы.

 

Общим требованием при патчинге программы в памяти является полная «неподвижность» программы, чего проще всего достигнуть, отправив все потоки модифицируемого процесса в состояние «замороженности» вызовом функции SuspendThread или при помощи старого фокуса «MySelf: jmp MySelf». Связано это с тем, что модификация кода с точки зрения программы должна производиться одномоментно, чтобы во время патчинга программа случайно не попыталась выполнить не до конца модифицированный код (результатом чего почти наверняка будет сбой). Из этого правила есть важное следствие: если патчинг выполняется одной ассемблерной командой (а одной командой можно переписать один, два, четыре, а при большом желании – 8 или 10 байт), то такая модификация будет одномоментной и потому вполне допустимой. К примеру, «волшебная» комбинация MySelf: jmp MySelf (в шестнадцатеричном виде этот код выглядит как EB FE) как паз является двухбайтной. Однако если Вы выполняете патчинг чужого процесса при помощи функций WinAPI, Вы не можете наверняка знать, блоками какого размера будет осуществляться запись в чужое адресное пространство, а потому пытаться переписывать более одного байта без «заморозки» процесса не стоит.

 

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

 

Самым оригинальным подходом к проблеме отличился, пожалуй, довольно старый патчер процессов, выпущенный T3X: все обязанности по отслеживанию загрузки эта утилитка возлагала на конечного пользователя. Созданный при помощи этой утилиты launcher просто запускал нужную программу, а затем выводил MessageBox с сообщением вроде «Нажми ОК, когда программа загрузится». От пользователя, соответственно, требовалось кликнуть по кнопке ОК, когда он сочтет, что подопытная программа уже загрузилась и распаковалась. Несмотря на откровенный примитивизм, этот патчер вполне соответствовал духу популярных защит того времени и позволял решать насущные задачи.

 

Естественно, следующим шагом в определении наилучшего момента для патчинга стала привязка к появлению на экране какого-либо окна, создаваемого модифицируемой программой. Поскольку большинство Windows-приложений являются оконными и при запуске создают окно, в котором предстоит работать пользователю, появление такого окна может служить недвусмысленным указанием на то, что распаковка программы уже завершилась и потому можно приступать к патчингу. Одним из первых такой патчер выпустил yoda, причем его патчер позволял не только обнаруживать появление окна, но и принудительно его закрывать (что было полезным для борьбы с nag screen’ами). Однако этот инструмент имел ряд недостатков, в частности – требовал указания полного заголовка окна, что не позволяло работать с окнами с изменяющимся заголовком. А поскольку окна со счетчиками оставшихся дней «испытательного срока» встречались в программах все чаще и чаще, такая ситуация сподвигла меня на написание собственного инструмента – интерпретатора скриптов с механизмом поиска окон по содержащейся в заголовке окна подстроке, возможностью имитировать нажатие кнопок в окне, активировать отключенные управляющие элементы и т.п. Однако и этот инструмент не во всех случаях способен решить проблему своевременного патчинга: защита может быть устроена таким образом, что в момент появления окна будет уже поздно что-либо предпринимать. Кроме того, нельзя сбрасывать со счетов безоконные программы, которые хоть и редко, но все же встречаются в мире Win32-приложений. И тогда приходится применять старый, весьма расточительный с точки зрения расхода ресурсов процессора, но проверенный временем и практически безотказный прием.

 

Традиционно проблема определения момента распаковки программы решается следующим образом: подопытная программа запускается при помощи launcher’а с флагом CREATE_SUSPENDED, этому процессу и его главному потоку назначается наименьший возможный приоритет, после чего главный поток «размораживается» функцией ResumeThread. Программа начинает распаковываться, но делает это очень медленно: во-первых, по причине минимального приоритета, а, во-вторых, потому что launcher в это время постоянно считывает содержимое байтов, которые предполагается пропатчить и сравнивает их с эталонными значениями, заранее добытыми из «живой» программы при помощи дампера или отладчика вроде SoftIce. Цель этих действий заключается в следующем: исправление необходимо провести как можно раньше после того, как подвергаемые патчингу участки будут распакованы. Как только содержимое нужных ячеек памяти совпадет с эталонами, программу можно считать готовой к внесению модификаций. В этот момент патчер стопорит исполнение программы и модифицирует код, а затем возвращает в исходное состояние приоритеты, размораживает все потоки и позволить программе выполняться дальше как ни в чем не бывало.

 

Блок, выполняющий постоянное сканирование, желательно оптимизировать по скорости настолько, насколько это возможно: если наш launcher не успеет внести в программу изменения до того, как управление будет передано на изменяемые участки, всю операция по патчингу можно считать проваленной. Именно для того, чтобы снизить вероятность «слишком быстрого» запуска программы и увеличить промежуток времени, пригодный для патчинга, мы и понижаем приоритет программы, отбирая у нее кванты процессорного времени в пользу нашего launcher’а и других программ. Из всего вышесказанного следует интересный практический вывод, который, к сожалению, не учитывается в большинстве существующих патчеров процессов: если патч предполагается использовать на мультипроцессорной системе, Ваш launcher и подопытную программу (или хотя бы ее главный поток) лучше исполнять на одном и том же процессоре. Добиться этого под Windows NT можно при помощи функций SetProcessAffinityMask, SetThreadAffinityMask и GetProcessAffinityMask.

 

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

 

На этом наш экскурс в теорию и методологию патчинга можно считать законченным. Думаю, Вы достаточно разобрались в основах самой важной и самой употребительной крэкерской операции. А стало быть, подходит к логическому завершению и эта работа. И единственная область, которая до настоящего момента мною почти не затрагивалась – это технологии будущего, «крэкинг завтрашнего дня». А потому темой заключительной главы будут подходы, только-только утверждающиеся на крэкинг-сцене, а также идеи, которые пока еще никто не осуществил.


Глава 13.

Заключение.

 

Не было начала – не было конца…

Е. Летов

 

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

 

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

 

Впрочем, иногда нужный крэк находится: крэкерские группы и просто собиратели серийников исправно снабжают общественность средствами нетрадиционной регистрации программ. И все-таки большинство крэкеров – одиночки, не ищущие громкой славы, и потому их релизы редко выходят за рамки круга личных знакомых. Но и у крэкеров жизнь – не сахар. Чем популярнее платформа – тем больше под нее программ, а чем больше программ – тем больше среди них программ защищенных. Да и сами защиты отнюдь не стоят на месте: если в конце 90-х годов упакованная программа под Win32 была редким гостем на «операционном столе», то сейчас сжатый EXE – скорее правило, чем исключение. Хорошо, если какая-нибудь добрая душа поделится с обществом распаковщиком, да этот распаковщик еще окажется работающим. А если не поделится или не окажется? Тогда одна дорога – дампить, править, восстанавливать или же сооружать launcher. И все же рано или поздно приходит понимание того, что как ни старайся, все программы не переломать. Так что если какую-то программу все еще не сломали, это отнюдь не повод превозносить сложность и надежность ее защиты. Возможно, просто эта программка не попалась на глаза умелому крэкеру. А может случиться и так, что программу эту сломали, причем не один раз, но исключительно для домашнего пользования. Но никто об этом не узнает.

 

Очевидно, что разрыв между желаниями широкой пользовательской общественности и возможностями крэкеров будет тем шире, чем больше программного обеспечения будут выпускать разработчики (собственно, единственный смысл установки защиты в наше время не в том, чтобы «не смогли сломать», а чтобы не ломали все, кому не лень, т.к. «любая программа, которую можно запустить, может быть взломана»). Каким образом можно переломить эту тенденцию? На ближайшую перспективу выход видится в том, чтобы дать квалифицированному пользователю возможность решить хотя часть его проблем самостоятельно. То, что пользователь, вооруженный подходящим программным обеспечением, способен сделать некоторые вещи, которые ранее считалось исключительно прерогативой «ребят с отладчиками», показала программа GameWizard и ее многочисленные клоны, предназначенные для «взлома» игр. Хотя с высот «чистого искусства» такое достижение кажется совершенно несерьезным, не стоит забывать, что совсем недавно проблема бесконечной жизни решалась лишь двумя способами – с помощью «знакомого хакера» или никак. Поэтому не стоит запираться в «башне из слоновой кости», оставляя пользователей наедине с их бедами, чтобы в итоге оказаться на обочине прогресса, но есть смысл заняться созданием инструментов, которые могут реально помочь широкому кругу пользователей (тем более, что в этом кругу рано или поздно оказывается и сам крэкер).

 

Многообещающим направлением видится создание программ, позволяющих пользователю самостоятельно устранять проблемы, связанные с ограничениями в программах. Проще говоря, обходить всевозможные триальные механизмы, причем по возможности – в полностью автоматическом режиме. «Первыми ласточками» в этой области были многочисленные утилиты, менявшие системное время перед запуском программы и через некоторое время возвращавшие его обратно. По вполне понятным причинам такой подход не всегда приемлем, но до недавнего времени ничего другого не предлагалось. В теории одним из путей, позволяющих «двигать» время внутри одной программы, не затрагивая все остальные, является перехват функций Windows API, так или иначе возвращающих локальное или системное время, и модификация возвращаемых значений, возвращаемых этими функциями. Однако путь от теории к практике оказался неблизким: долгое время эта идея не получала практической реализации, по крайней мере, готовые программные продукты, пригодные для использования рядовым пользователем, автору не встречались. И лишь сравнительно недавно появилась программа Hall of the Mountain King, которая позволяет не только менять значение «внутренних часов» для любого процесса, но и «закольцовывать» течение времени внутри программ.

 

Весьма многообещающе выглядят и инструменты, которые позволяют скрывать и защищать те или иные ключи реестра от доступа из определенных процессов, используя технику перехвата вызовов WinAPI. В настоящее время автору не известны программы такого рода, которые могли бы использоваться для борьбы с триальными ограничениями, но сама по себе эта идея выглядит заманчиво: запретив, к примеру, чтение из ключей HKEY_LOCAL_MACHINE\Software\Classes\CLSID и HKEY_CURRENT_USER\Software\ASProtect для программ, защищенных ASProtect, можно добиться того, что защита «не увидит» собственные триальные метки и будет считать каждый запуск первым.

 

Если довести идею «учета и контроля», на которой основаны две описанных выше технологии, до логического завершения, мы в итоге придем к мысли о создании «firewall’а для системных вызовов», то есть программы, надзирающей за обращениями к функциям WinAPI, ведущей журнал этих вызовов и блокирующей либо корректирующей «неправильные» обращения к системе. Максимальную гибкость такого инструмента можно было бы обеспечить встраиванием быстрого скрипт-интерпретатора, при помощи которого пользователь мог бы программировать логику работы с системными вызовами. Возможности, предоставляемые такой программой, совмещающей в себе возможности API-шпиона и DLL-враппера, было бы трудно переоценить. К примеру, вместо того, чтобы долго выламывать nag screen из строптивой программы (почти наверняка столкнувшись при этом с необходимостью ее распаковки), можно было бы просто запустить программу в режиме снятия лога системных вызовов, чтобы понять, каким образом формируется и отображается nag screen. После чего, основываясь на полученной информации, выяснить в какой точке программы производится вывод nag screen’а и написать скрипт, который бы отказывал этому вызову в праве на создание окна, но при этом «пропускал» все остальные. Подобным же образом можно было бы неограниченно продлять триальные сроки, «доработав» функции работы с реестром так, чтобы в действительности запись в некоторые ключи реестра не производилась, но при этом подопытная программа пребывала в полной уверенности, что триальные метки успешно расставлены. Нужно отметить, что такая программа будет уже не орудием исключительно крэкинга, но инструментом двойного назначения, который может быть использован для обнаружения утечек ресурсов или как средство автоматизации.

 

Другой подход к решению проблемы триалов, окончательно оформившийся сравнительно недавно, заключается в автоматизации поиска и удаления «меток», по которым программы определяют количество запусков или промежуток времени, оставшийся до истечения срока пробного использования. О технологиях выявления таких меток мы уже говорили в третьей главе, однако там делался упор на «ручную работу» по поиску лишних ключей в реестре и аккуратное с ними обращение, чего весьма трудно добиться при работе с большим количеством защищенных программ. Такой подход неприемлем для рядового пользователя, которого совсем не прельщает необходимость перед каждым запуском делать снимки реестра и потом долго их сравнивать, размышляя о том, за какое из изменений в реестре ответственна защита. Современный стиль жизни таков, что любая проблема должна решаться «в два клика», без многочасовых медитаций над снимками реестра и отчетами RegMon’а. Но есть ли пути к достижению этого идеала? Разумеется, есть! Широкое распространение навесных защит, реализующих триальные ограничения, привело к тому, что способы нанесения триальных меток тоже «унифицировались». Каждый тип навесных защит имеет свои собственные «предпочтения» в том, где эти метки создавать и каким образом их генерировать. Следовательно, если собрать достаточно большую коллекцию программ, защищенных одним и тем же инструментом, и как следует погонять их во всех возможных режимах, можно собрать некоторую статистику. К примеру, ASProtect традиционно мусорит в ветке реестра HKEY_LOCAL_MACHINE\Software\Classes\CLSID, причем старые версии этой защиты создавали ключи с одним-единственным текстовым значением длиной 4, 8 или 16 байт; главная надежда разработчика, видимо, заключалась в том, что огромное количество ключей в этой ветке помешает найти среди них «лишние». Как показала практика, это мнение было глубоко ошибочным и более новые версии этой же защиты пытаются имитировать полезные ключи гораздо более тщательно, что, впрочем, тоже не слишком хорошо помогает, потому что исследователи защит тоже не сидят сложа руки.

 

Изучив достаточное количество одинаково защищенных программ и поразмыслив над собранной информацией можно попытаться выделить ключевые признаки, позволяющие отличать «защитные» ключи от тех, которые выполняют более полезные функции и, соответственно, удалить первые. Возможных признаков, отличающих «праведные» ключи и файлы от «неправедных» - неисчислимое множество: это может быть и тип хранимых данных, и их размер, и наличие заведомо бессмысленных комбинаций символов там, где должен быть текст, и «ошибочные» ссылки на файлы и другие ключи реестра... Готовое решение здесь, как водится, заранее предложить невозможно, и если Вы возьметесь за создание инструмента для очистки систмы от триальных меток, Вам придется немало поломать голову над вычленением отличительных признаков «ненужных» данных и изобретением хорошего алгоритма очистки системы от этой информации. Идеальным объектом для получения такой информации является сама программа, при помощи которой устанавливается защита: одно лишь чтение документации может подсказать верное направление для исследований, не говоря уже о возможности штамповать защищенные программы-»пустышки» для экспериментов в любом количестве. Ну и, разумеется, изучение интерфейса программы (даже если самая интересная функциональность заблокирована) способно многое рассказать о возможностях и особенностях исследуемой защиты.

 

Основываясь на опыте разработки одной из таких программ-очистителей реестра, я сделал вывод о том, что для инструментов подобного рода важно соблюдение принципа максимального разделения интерфейсной части и функциональной. Именно «монолитность» Die, ASProtect, Die! и привела к тому, что после некоторого момента эту программу было бы проще переписать «с нуля», чем поддерживать, основываясь на исходной идеологии хранения нескольких пользовательских интерфейсов и алгоритмов поиска в одном исполняемом файле. Изложение проекта «идеального очистичеля реестра» слишком далеко увело бы нас от основной темы, поэтому я ограничусь лишь несколькими тезисами:

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

 Реализация функциональной части программы в максимально «легком» (т.е. небольшом по размеру и удобном для модификации) виде. Мир программных защит весьма динамичен, и не стоит надеяться, что единожды «освоив» какую-либо защитную схему, можно до скончания века пользоваться плодами своих усилий. В еще большей мере это относится к созданию программ, ориентированных на полную автоматизацию обхода защит: если действия крэкеров для разработчика есть нечто неприятное, но неизбежное, то самое существование инструментов, дающих рядовому пользователю власть над триальными ограничениями, воспринимается как покушение на основы мироздания. Когда разработчик защит видит, что его изделие в достаточной мере «освоено», ему не остается ничего иного, как выпустить новую версию своего продукта, неуязвимую для существующего антитриального софта. Следовательно, Ваши программы должны быть максимально легки для обновления и расширения, чего можно достичь через использование плагинов или интерпретатора сценариев. Преимущество плагинов, написанных на компилируемых языках, в скорости их работы; сила скриптов – в легкости модификации (в качестве тестера и соавтора скрипта может выступить даже квалифицированный пользователь) и минимальных размерах. С другой стороны, плагины в виде двоичных исполняемых модулей неудобны для доработки и распространения, а интерпретируемые скрипты проигрывают плагинам в быстродействии и к тому же весьма уязвимы для обратного инжениринга (было бы крайне наивным полагать, что разработчики защит не анализируют инструменты «другой стороны», даже если в лицензионным соглашении такие действия явным образом запрещены).

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

 

Однако «пользовательским» уровнем грядущие возможности отнюдь не исчерпываются. Уже сейчас можно видеть, как возвращаются в новом обличье старые, но до времени не актуальные идеи. Около десяти лет назад была весьма популярна концепция эмулирующих отладчиков, которые практически полностью имитировали работу центрального процессора (исключение делалось разве что для команд вызова прерываний) и позволяли исполнить программу так, как она исполнялась бы на настоящем процессоре, но при этом иметь абсолютный контроль над состоянием отлаживаемой программы. Следы работы такого отладчика было практически невозможно обнаружить, не прибегая к ухищрениям; многие приемы, направленные на нарушение стабильности отладчика, также оставались не у дел. Однако с наступлением эры многозадачных ОС этот род отладчиков как-то тихо канул в Лету. Долгие годы эмуляция была не в почете, однако некоторые признаки указывают на то, что в ближайшем будущем возможен ренессанс эмулирующих отладчиков. Эмуляция длинных кусков кода, не содержащего обращений к WinAPI, освоена довольно давно: одних только плагинов для Interactive Disassembler, позволяющих имитировать исполнение кода, насчитывается уже несколько штук. Главная проблема, ожидающая своего решения – организация взаимодействия между эмулятором и вызовами «неудобных» функций системного API внутри эмулируемой программы (попробуйте поразмыслить над тем, как можно эффективно проэмулировать обращение к функции, порождающей новый поток или вызывающей callback-функцию, чтобы представить уровень сложности задачи).

 

Другой приметой времени следует признать все более и более широкое распространение виртуальных машин, более или менее точно воспроизводящих поведение настоящих, «железных» ЭВМ. Из программных продуктов этого рода, эмулирующих платформу x86, наибольшей популярностью пользуются Virtual PC и VMWare, причем VMWare существует не только в Windows-, но и в Linux-версии, что позволяет крэкеру не ограничивать себя в выборе основной операционной системы. Думаю, что многие уже оценили удобства, предоставляемые этим инструментом: отладку внутри гостевой ОС параллельно с работой в хост-системе, возможность в любой момент восстановить систему вместе со всеми необходимыми инструментами, защищенность хост-системы от действий деструктивного кода (некоторые разработчики все еще встраивают в свое ПО такие закладки, не считаясь с возможными последствиями) и другие полезные мелочи вроде возможности снятия скриншотов окна SoftIce. Однако потенциально виртуальные машины способны дать крэкеру гораздо больше, чем предлагают существующие инструменты. В первую очередь я имею в виду возможность интеграции отладчика с виртуальным процессором. Разработчикам SoftIce пришлось немало потрудиться, чтобы создать почти полностью «прозрачный» для системы отладчик, но даже этот замечательный инструмент имеет немало ограничений. Одни из них накладываются самим устройством процессора (например, невозможность установки более четырех аппаратных точек останова), другие заключаются отсутствии поддержки того или иного «железа», третьи кроются в недрах программного кода отладчика (проявляющаяся иногда нестабильность в работе, «дыры», позволяющие обнаружить присутствие SoftIce, невозможность просматривать тексты в национальных кодировках и т.п.). Корнем всех этих бед является то, что отладчик работает на той же самой программно-аппаратной системе, что и отлаживаемая программа. Вот если бы каким-то образом удалось вынести отладчик за пределы компьютера... Если подойти к решению этой проблемы «в лоб», мы придем к идее аппаратной приставки, берущей под контроль все процессы, протекающие внутри системного блока. И такой подход действительно иногда используется, но не для взлома, а для тестирования всевозможного встраиваемого ПО - от новых версий BIOS до управляющих программ бортовых ЭВМ космических спутников. Гонять такие «железки» (надо отметить, в настоящее время весьма не дешевые) исключительно в целях крэкинга – это, пожалуй, явный перебор. Но вот если реализовать такой комплекс исключительно программно, да подключить его к виртуальному же процессору не менее виртуального PC, то можно будет получать любую информацию о состоянии нашей эмулированной машины. Это было бы очень изящное и практичное решение: Вас не волновали бы ограничения на число отладочных регистров, не нужно было бы думать о том, поддерживается ли Ваша видеокарта отладчиком, а сам отладчик смог бы предоставить такие сервисные возможности, какие невозможны в SoftIce, «завязанном» на работу исключительно в текстовых режимах. Аналогичный же вариант можно было бы проделать и с прочим оборудованием, например, предоставив API для создания собственных программных модулей, эмулирующих внутренние и внешние устройства. Разумеется, это породило бы виток соревнования «брони и снаряда», и к бесчисленным антикрэкерским пособиям «как определить наличие отладчика» добавились бы изыскания о проверке обрудования, с которым работает программа, на виртуальность (а защиты с такими механизмами уже существуют, правда, направлены они в основном против эмуляторов CD-ROM), но тут уж ничего не поделаешь – таковы законы жанра. Собственно, отладчики, интегрированные с виртуальными машинами, не являются чем-то ранее невиданным: наиболее продвинутые эмуляторы машин линейки Spectrum, БК и Atari позволяют не только запускать программы, но и отлаживать их «по живому» со всеми удобствами. Возможно, в эпоху 128-разрядных ЭВМ эмуляторы PC обретут такие же возможности, но современному ПО в этой области пока похвалиться нечем.

 

Не стоит забывать и о многочисленной армии специализированных мобильных устройств. Лавинообразный рост количества таких аппаратов и расширение их функциональности позволяет предположить, что «мобильный крэкинг» в ближайшее время станет столь же актуален, как и классический, ориентированный на софт для персональных ЭВМ. И если «наладонники» достаточно мощны и сложны, чтобы позволять реализовывать сложные защиты, то во многих мобильных телефонах и смартфонах в настоящее время широко используется урезанный вариант языка Java. А там, где есть Java и прочие интерпретаторы байт-кода, всевозможные декомпиляторы приобретают просто убийственную эффективность: сравнительно низкая производительность и ограниченные возможности мобильных устройств сильно мешают использованию «замусоривателей» (obfuscator’ов) кода и интеграции защитных механизмов в код программы, а технологии декомпиляции Java-программ настолько хорошо отработаны, что позволяют получить листинг, идентичный исходному. Из всего этого, в свою очередь, следует исполнение вековечной мечты крэкеров, а именно – возможность декомпилировать программу в исходный текст на языке высокого уровня, внести в него любые исправления и с минимальными усилиями скомпилировать обратно без потери работоспособности. Кроме того, для многих популярных мобильных платформ существуют общедоступные эмуляторы и SDK, позволяющие исследовать и дорабатывать программы для мобильников даже не имея в наличии настоящего, «железного» телефона.

 

И, наконец, последние направление, обещающее огромные возможности, связано с появлением open-source ОС, совместимой с Windows. Если Вы уже пытались применить свои знания на практике, Вы не могли не заметить ограниченность либо нестабильность многих крэкерских инструментов. Зависания отладчиков, вызовы API, которые в упор не замечают API-шпионы, невозможность нормально снять дамп памяти – такова объективная реальность, с которой приходится сражаться крэкеру. Все это можно было бы списать на ограниченность и недостаточное качество используемого программного обеспечения, тем более, что авторы многих из этих программ изначально не ориентировались на потребности крэкеров и потому было бы странно требовать от них возможностей, которые не были заявлены в проекте. Однако если рассматривать вопрос с точки зрения архитектора, нам откроется, что «корень зла» лежит не в несовершенстве программ (хотя наличие такового несовершенства нельзя отрицать), но в способах, при помощи которых эти программы взаимодействуют с ОС. А взаимодействие это нередко осуществляется «с черного хода», при помощи приемов, работоспособность которых держится на многочисленных «авось». И в этом нет ничего удивительного: при разработке ОС штатные механизмы перехвата системных вызовов не были запроектированы в принципе, поэтому единственным методом удовлетворения возникших потребностей оказался метод «грязного хака» (который, впрочем, впоследствии был санкционирован самим производителем ОС). Образно говоря, система «ОС+отладчик» или «ОС+API-шпион» с высоты птичьего полета выглядит не как готический замок, в котором каждая башенка является частью единого целого, а как глинобитный сарай, прилепленный к стене суперсовременного небоскреба. Неудивительно, что прочность такой конструкции – совершенно никакая, и рушится она чаще всего именно в месте, где «небоскреб» соединяется с «сараем». Наиболее естественным выходом из сложившейся ситуации была бы интеграция средств наблюдения за взаимодействием между пользовательскими программами и операционной системой непосредственно в ядро ОС, но по вполне понятным причинам корпорация Microsoft на такой шаг не пойдет. Однако в последнее время появилось несколько весьма интересных проектов с доступными исходными текстами - от эмуляторов Windows (Wine/WineX/Cedega) до разработки полноценной «другой Windows» (проект ReactOS). Наличие исходных текстов позволяет встраивать непосредственно в ядро ОС практически любые механизмы слежения за пользовательскими программами и самой ОС, а также API для управления этими механизмами. Причем такие «усовершенствования» ОС совершенно необязательно внедрять в код жестко, их можно оформить как патчи, накладываемые на исходные тексты ядра и применяемые при необходимости. Возможности же, предоставляемые отладчиком или API-шпионом, интегрированным в ядро операционной системы, трудно переоценить.

 

Однако изменятся не только орудия крэкерского труда – изменится и сам крэкинг, и, изучая предмет, этот факт не стоит сбрасывать со счетов. Уже сейчас очевидны две тенденции: с одной стороны, продвижение наиболее простых крэкерских технологий в массы (о чем я говорил выше), и с другой стороны – нарастание специализации, сначала - в областях знаний, а затем – и во взломе конкретных продуктов. Десять-пятнадцать лет назад крэкер вынужден был становиться «мастером на все руки» и не только уметь разбираться в кодах чужих программ, но и обеспечивать себя необходимыми инструментами для этой деятельности – Интернета в современном его виде, откуда всегда можно выкачать свежий отладчик или распаковщик, тогда просто не существовало. Разумеется, такая ситуация сложилась не от хорошей жизни – в отсутствие Интернета свободный обмен информацией и, тем более, инструментами был крайне затруднителен – всевозможные BBS и «флоппинеты» мало чем способны были помочь, да и существовали далеко не везде. Разработчикам защит, впрочем, было не легче: «навесные» защиты не то, чтобы совсем отсутствовали, но были не слишком популярны, и в итоге каждая защищенная программа была единственной в своем роде, но с другой стороны набор защитных приемов был куда более ограничен по причине простоты процессоров и операционных систем того времени. Нынешняя ситуация отличается в корне: с одной стороны, количество программ выросло на порядки но с другой – сами авторы ПО обычно идут одним из двух проторенных путей: либо читают всевозможные руководства «как защитить программу» и затем более или менее успешно воспроизводят книжные схемы, либо покупают готовый продукт и «в два клика» прикручивают защиту, нимало не интересуясь, что они прилепили к своему коду. Следствием такой защитной стратегии стало то, что знание о том, как ломается та или иная типовая защита, является ключом не к единственной программе, но к множеству. Итогом всего этого становится ярко выраженная ориентация на изучение именно типовых, популярных защит (потому как это – способ минимальными силами «разобраться» с максимумом программ и ломать их конвейерным методом), а это есть ни что иное, как специализация. Впрочем, какой бы путь Вы для себя не выбрали, существуют области, в которых необходимо разбираться каждому, кто так или иначе собирается уделить время крэкингу, и на изучение именно этих вещей и стоит делать упор в первую очередь. Не стоит совершенно игнорировать и малопонятную или неактуальную информацию: даже если сейчас Вы не можете применить эти знания, постарайтесь усвоить их хотя бы в общих чертах – возможно, в будущем именно эти «заметки на полях памяти» лягут в основу изящного подхода к какой-нибудь проблеме. Многие из старых пособий по крэкингу сейчас видятся пыльным антиквариатом, поскольку те программы, которые в них описаны, давно и бесповоротно канули в прошлое. Но первое впечатление обманчиво: хотя коды из этих статей устарели, иные решения, приведенные в этих статьях, вызывают восхищение и сейчасаже спустя годы татьях, до сих пор.и, но и бесповоротно канули в прошлое, однако на самом деле . Помните, что не стоит зацикливаться на одних лишь крэкерских материалах; очень часто бывает полезно взглянуть на проблемы защиты и взлома с «другой стороны баррикад». Так уж исторически сложилось, что авторы защит не слишком охотно предоставляют информацию о своих изделиях, однако даже беглое изучение содержимого соответствующих руководств и форумов даст Вам представление, чем дышит «вероятный противник», какие идеи сейчас популярны в среде разработчиков ПО и - если повезет - в чем заключаются огрехи и недоработки популярных защитных схем. Знание о новом защитном приеме, кроме всего прочего, представляет собой отличный повод поразмыслить о том, как соответствующий код может быть идентифицирован в реальной программе и нейтрализован. Я уже давал этот совет в одной из первых глав, но повторю его еще раз в завершении: во всяком источнике информации нужно искать не столько приемы, сколько идеи, а знакомство с оригинальными идеями в свою очередь стимулирует способность находить решения самостоятельно. И какой бы Вы ни избрали путь – скромного разработчика инструментов, кующего «оружие Победы» или «бойца невидимого фронта», раскалывающего особо прочные защиты, потрошителя двадцатидолларовых утилит или мирного исследователя недр операционной системы – не бойтесь отойти от канонов и проявить неоправданную оригинальность. В конце-концов, изящные решения – это те нити, из которых соткано будущее. И это будущее обещает быть интересным – так почему бы не добавить в него что-то от себя?

 


Дата добавления: 2019-08-30; просмотров: 166; Мы поможем в написании вашей работы!

Поделиться с друзьями:






Мы поможем в написании ваших работ!