Теоретические основы крэкинга: Глава 9. Если бряк оказался вдруг…

Дата публикации 4 янв 2005

Теоретические основы крэкинга: Глава 9. Если бряк оказался вдруг… — Архив WASM.RU

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

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

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

Ненамного отстают по популярности среди авторов защит всевозможные приемы определения присутствия отладчиков, от откровенно примитивной проверки наличия определенных файлов/ключей реестра (отдельные разработчики защит даже удаляют эти ключи, нимало не утруждая свой беспросветно могучий интеллект мыслями о том, что SoftIce можно приобрести легально и использовать не для взлома их поделок) до довольно изощренных антиотладочных приемов, использующих особенности конкретных отладчиков. Примерами таких особенностей могут служить «черные ходы» в SoftIce для взаимодействия с Bounds Checker’ом или нездоровая реакция на вызов IsDebuggerPresent в OllyDebug и всех остальных отладчиках, использующих Debug API. Кстати, признаки наличия отладчика могут быть не только информационными, но и физическими: программа может «догадаться» о том, что ее ломают, по ненормально большому времени выполнения тех или иных процедур. Задумайтесь над тем, сколько времени уходит на выполнение десятка команд в «ручном режиме», когда Вы исступленно давите кнопку F8 в OllyDebug - и Вы сразу поймете, что я имею в виду. К этой же группе можно отнести использование в защитных механизмах отладочных регистров процессора: поскольку эти регистры используются отладчиком для установки брейкпойнтов, одновременная их эксплуатация программой и отладчиком невозможна, если попытаться проделать такое, либо отладчик «забудет» аппаратные точки останова, либо защитные процедуры выдадут некорректный результат со всеми вытекающими из этого последствиями. Большинство антиотладочных приемов, разумеется, давно и хорошо известны, и их описание несложно найти в руководствах по крэкингу и написанию защит. Впрочем, авторы защит на такие приемы обычно всерьез не рассчитывают, поскольку идентифицировать (а часто – и обойти) антиотладочный код в дизассемблерном листинге обычно несложно (например, если прикладная программа пытается оперировать с отладочными регистрами, это очевидный признак того, что в коде «что-то нечисто»), а некоторые крэкерские инструменты среди своих функций имеют отслеживание популярных антиотладочных приемов (примером может служить старая утилита FrogsIce, которая умела выявлять и побеждать множество защитных трюков, направленных против SoftIce).

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

 

Если функции WinAPI безраздельно властвуют в темном и мрачном царстве невизуальных объектов, таких, как файлы, процессы, средства синхронизации и прочее, то в «оконной» области ситуация отличается разительным образом. Сравнительно небольшой набор системных вызовов общего назначения («создать-включить-удалить окно») с лихвой компенсируется  огромным разнообразием системных сообщений (в англоязычной документации – «messages»; общее число документированных сообщений уж перевалило за тысячу), подчас дублирующих функции WinAPI (например, сообщение WM_GETTEXT, которое способно читать текст окна не хуже, чем уже известная Вам функция GetWindowText). Некоторые типы управляющих элементов, такие, как обычные или выпадающие списки, вообще не имеют полноценной «обвязки» функциями Win32 API и управляются с ними именно при помощи сообщений. Вы не сможете добавить в такой список строчку или перейти к нужной позиции, вызвав WinAPI’шную функцию с названием вроде ComboBoxAddString или ComboBoxSetPos – таких функций в системных библиотеках Windows просто нет. Зато есть сообщения CB_ADDSTRING и CB_SETCURSEL соответственно, воспользовавшись которыми, Вы легко выполните задуманное. То есть, сообщения играют роль параллельного механизма управления объектами ОС, на работу которого совершенно не влияют традиционные брейкпойнты, которые мы щедрой рукой сеяли в предыдущей главе.

Поскольку сообщение – не функция, брейкпойнт на него поставить нельзя. Но очень хочется. А если очень хочется – значит, все-таки можно, хотя и не так просто, как хотелось бы. Прежде всего, Вам нужно определиться, что именно Вы хотите отловить – момент и точку отправки сообщения, либо подпрограмму обработки этого сообщения. Если Ваc интересует второй вариант и Вы являетесь поклонником SoftIce – считайте, что о Вас уже позаботилась фирма NuMega (ныне - Compuware). Встроенная в SoftIce команда BMSG как раз для этого и предназначена, но чтобы успешно ее использовать, Вам понадобится узнать хэндл окна, которому предназначено сообщение. Если нужные данные у Вас имеются – просто набирайте BMSG <хэндл_окна> <код_сообщения>, и ждите, когда «всплывет» отладчик. Разумеется, команда BMSG, как и любая другая команда установки брейкпойнтов, позволяет создавать условные точки останова, срабатывающие, например, при поступлении сообщений только с определенными значениями wParam и lParam.

А что делать тем, кто пользуется другими отладчиками, в которых аналог BMSG отсутствует? Ответ на этот вопрос находится, как ни странно, именно в руководстве по SoftIce. В частности, там написано, что действие команды BMSG может быть воспроизведено установкой условного брейкпойнта на оконную процедуру, причем в качестве условия нужно указать следующее: IF (esp->8)==<имя_сообщения>; адаптация этого условия под синтаксис, принятый в конкретном отладчике, обычно сложности не представляет, хотя вместо символьного имени сообщения скорее всего придется подставить его код (коды сообщений можно найти в файлах windows.inc, winnt.h или Messages.pas – в зависимости от того, компилятор какого языка у Вас есть под рукой; те, кто не обзавелся подходящим компилятором и не планируют им обзаводиться в ближайшем будущем, могут заглянуть в файл messages.lst из состава InqSoft Window Scanner).

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

Кроме того, при каждом запуске программы хэндлы меняются, что тоже отнюдь не упрощает отладку. А вот оконная процедура всегда находится на одном и том же месте (справедливости ради надо отметить, что создание «плавающей» по адресному пространству от запуска к запуску процедуры теоретически возможно, хотя я такое и не встречал). И потому адрес этой процедуры можно просто записать на бумажке, чтобы затем восстанавливать соответствующий бряк без каких-либо сложностей. Нам осталось только раздобыть адрес этой самой процедуры. Тут тоже, в принципе, ничего сложного нет – разумеется, если под рукой имеются соответствующие инструменты (к примеру, Microsoft Spy++ или все тот же InqSoft Window Scanner). Наведите «прицел» программы на интересующее Вас окно и прочитайте желанный адрес оконной процедуры собственно окна (обычно этот адрес обозначается как WndProc) или оконной процедуры, сопоставленной классу окна.

Иной путь получения адреса оконной процедуры заключается в том, чтобы при помощи API-шпионов обнаружить системный вызов, при помощи которого производится регистрация класса окна (функции WinAPI RegisterClass и RegisterClassEx) либо непосредственно создание окна (список соответствующих функций я приводил в предыдущей главе). Операция эта выполняется в три этапа:

  1. Запускаем под API-шпионом, настроенным на отслеживание процедур создания окон, и ждем появления нужного окна.
  2. Как только окно появится – останавливаем работу шпиона и при помощи любого сканера окон получаем хэндл этого окна.
  3. Если адрес оконной процедуры находится среди параметров функции создания окна - ищем в логе, сгенерированном API-шпионом, функцию, которая возвращает значение нашего хэндла, и считываем ее параметры, среди которых находим искомый адрес оконной процедуры.
  4. Если адрес оконной процедуры находится в структуре, указатель на которую передается в функцию регистрации классов – считываем адрес, откуда был произведен вызов этой функции. Затем загружаем программу в отладчик, ставим точку останова на этот адрес (или чуть выше – на том месте, где происходит запись в стек указателя на структуру, это уж как Вам больше понравится) и запускаем программу. Как только исполнение программы прервется на нашем брейкпойнте – находим в памяти структуру, указатель на которую передается в RegisterClass[Ex] и аккуратно переписываем содержимое поля этой структуры, содержащее адрес оконной процедуры для регистрируемого класса.

Вас может смутить сложность четвертого пункта, который выполняет весьма несложные функции, но при этом его описание едва ли не длиннее предыдущих трех. Казалось бы, что нам мешает просто вытащить из лога API-шпиона значение указателя на структуру, по-быстрому снять дамп нужной области и прочитать желанный адрес? В принципе, ничего не мешает – но вот истинное содержимое структуры WNDCLASSEX Вы таким способом вряд ли прочитаете – потому что скорее всего в момент снятия дампа эта структура уже давно будет затерта другими данными. Дело в том, что регистрация класса – событие разовое, и потому память под структуру, описывающую класс, редко выделяют статически; обычно же программист обходится для этих целей куском стека. Так что когда Вы заберетесь своим дампером в адресное пространство процесса, в том месте, где находилась желанная структура, давно уже будут лежать другие данные. И единственным решением в данном случае мог бы быть интеллектуальный API-шпион, которому можно было бы объяснить правила извлечения полей структур из памяти. К сожалению, на данный момент API-шпионы с такими свойствами мне не известны.

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

Я уже приводил пример того, как программа считывала дату своей установки под видом поиска плагинов в своей директории, и, разумеется, этим список возможных приемов маскировки одних действий под другие не исчерпывается. Программа eXeScope, например, в качестве сообщения об ограничении в незарегистрированной версии выдает окно, внешним видом точь-в-точь повторяющее стандартный MessageBox, но в действительности нарисованное визуальными средствами в Delphi. Отображение файла в память вместо обычного чтения в буфер – прием известный, и, тем не менее, чтение файла лицензии таким способом вполне может поставить в тупик начинающего крэкера. Я уж не говорю о таких изощренных техниках, как парсинг ini-файлов «вручную» (после чего можно очень долго возиться с точками останова на GetPrivateProfile* - разумеется, с нулевым результатом) или экспорт кусков реестра при помощи утилиты regedit во временный файл с последующим анализом этого файла (что позволяет обойтись без вызова функций работы с реестром внутри программы).

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

В свое время самым популярным отладчиком для  «Спектрума» был MONS (впрочем, некоторые люди, включая меня, предпочитали MON) – восьмикилобайтное порождение программистской мысли, способное загружаться в ОЗУ с любого адреса и управляемое из командной строки (прямо как SoftIce – внешнее сходство этих двух отладчиков вообще сложно не заметить). И, разумеется, MONS позволял ставить брейкпойнты – еще бы, не имея в своем арсенале такой возможности, этот отладчик вряд ли стал бы столь популярен. Но поскольку процессор Z80, на основе которого был сделан «Спектрум», никаких отладочных средств не предоставлял, авторам MONS пришлось реализовывать точки останова чисто программными средствами. Реализация эта красотой отнюдь не блистала – «установка брейкпойнта» по-Спектрумовски заключалась в подстановке в нужное место кода трехбайтной команды CALL xxxx, которая передавала исполнение в недра самого отладчика и таким образом приостанавливала исполнение пользовательского кода. Старые команды, код которых затирался брейкпойнтом, копировались в специальный буфер и дополнялись командой JP (аналог jmp из набора команд x86) для возврата к следующей команде, не испорченной CALL’ом. Исполнение в пошаговом режиме выглядело не менее оригинальным – исполняемая команда перебрасывалась в отдельный буфер, дополнялась все тем же JP, после чего отладчик передавал управление в этот буфер. Если еще вспомнить, что в Z80 существовали недокументированные команды, которые были известны далеко не всем отладчикам (и потому могли обрабатываться некорректно), отлаживаемая программа даже при абсолютно корректной работе могла испортить код отладчика, а под сам отладчик могло элементарно не хватить свободной памяти, и потому его загружали на место «ненужных» данных – Вы поймете, что представляла собой отладка в старые добрые времена.

Разработчики линейки x86 проявили больше заботы о программистах. В этой линейке процессоров вместо самодельной «затычки» в виде команды вызова подпрограммы для отладочных целей ввели отдельное прерывание с номером 3, которое вызывалось однобайтной командой (опкод команды int 3 – СС), в отличие от всех прочих прерываний, которые менее чем двумя байтами вызвать не получится. Другим полезным нововведением стала возможность исполнять код в пошаговом режиме через управление флагом трассировки (эта возможность, впрочем, мало актуальна для отладчиков пользовательского уровня под современные ОС). Однако, несмотря на такой, казалось бы, очевидный прогресс в развитии средств отладки, обыкновенные точки останова все так же, как и десятилетия назад, модифицируют исполняемый код, а потому легко обнаруживаются даже простейшими способами, например, проверкой контрольной суммы всех байтов (не говоря уже о CRC32 и использовании иных, еще более сложных и надежных хэш-функций). Самостоятельно убедиться в том, что точки останова модифицируют код, Вы можете за считанные секунды: откомпилируйте при помощи любого ассемблера следующие две строчки, возьмите OllyDebug и загрузите в него откомпилированный код.

Код (Text):
  1.  
  2. addr1: mov eax,addr1
  3. mov al, byte ptr [eax]

Если Вы просто выполните этот код в пошаговом режиме, то в регистре AL окажется число 0B8h.В этом нет ничего удивительного, B8 – это опкод команды mov eax, <число>. А теперь попробуйте поставить брейкпойнт на команду  mov eax,addr1 и снова оттрассируйте этот код. После выполнения второй команды Вы увидите, что в регистре AL находится число 0CCh, хотя код в окне отладчика внешне совершенно не изменился (если, конечно, не считать изменением подсветку адреса, на который поставлен брейкпойнт). Самое интересное, что отладчики могут «приукрашать реальность» не только в окне кода, но и при просмотре данных.

Давайте проделаем еще один весьма поучительный в этом смысле эксперимент: загрузим наш пример из двух команд, поставим точку останова на первую и запишите адрес этой точки останова. Затем берем InqSoft Window Scanner и читаем байт по записанному адресу. Получаем, разумеется, 0CCh. А теперь взглянем на ту же область глазами отладчика (в OllyDebug это пункт меню Follow in dump|Selection) – и очень сильно удивляемся. Отладчик показывает нам совсем не то, что реально читается из памяти в регистр AL, a то, что должно было бы находиться по указанному адресу, если бы мы не поставили точку останова. Но и это еще не все! Посмотрите на динамические подсказки под окном кода – там-то как раз содержимое памяти отображается как надо.

Вот так «умные» отладчики помогают самообманываться начинающим крэкерам: отсутствие видимых изменений в коде наводит человека, не знакомого с тайнами устройства брейкпойнтов, на мысль о том, что прерывание исполнения программы в точке останова происходит по воле неких таинственных сил, с которыми отладчик находится в телепатической связи. Хотя на самом деле «классические» точки останова – это ни что иное, как обычные memory patch’и – а потому и обнаруживаются теми же самыми способами, что и любые другие исправления в коде.

Кстати, из того, что обычный (не аппаратный) брейкпойнт является ничем иным, как исправлением программы, есть одно интересное следствие. Дело в том, что SoftIce’у в общем-то без разницы, каким образом в программе появилась команда int 3 – главное, что он может на этой команде остановиться не хуже, чем на настоящем брейкпойнте. А после того, как отладчик остановится, можно внести любые поправки в содержимое регистра EIP и код программы, после чего продолжить исполнение как ни в чем не бывало (собственно, в OllyDebug тоже можно проделать такую операцию при помощи пункта New origin here из всплывающего меню). Польза от такого эрзац-брейкпойнта (после срабатывания которого, к тому же, нужно вручную восстанавливать код, который находился на месте int 3 и править EIP), на первый взгляд кажется весьма сомнительной, но она есть. Я уже упоминал глюк в SoftIce, когда отлаживаемая программа после загрузки Symbol loader’ом начинает немедленно выполняться, хотя крэкеру хотелось бы ее в этот момент притормозить. Так вот, если в Entry point исполняемого файла воткнуть опкод 0CCh, у подопытной программы не будет ни единого шанса избегнуть процесса отладки – поскольку первой командой окажется наш int 3, принудительно активирующий отладчик.

 

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

На практике дело обстоит еще хуже – для удаления некоторых точек останова не нужны ни коды коррекции ошибок, ни резервные копии. И именно к таким точкам останова относятся всеми нами любимые BPX’ы на вызовы функций WinAPI (и, если смотреть шире, на вызовы практически любых функций). Поскольку «брейкпойнт на функцию» - это на самом деле всего лишь брейкпойнт на первый байт этой функции, самый простой из приемов, удаляющих точки останова, выглядит следующим образом: заранее узнать адреса нужных функций при помощи GetProcAddress, прочитать их первые байты (если речь идет о внутренних функциях программы – то просто прочитать содержимое соответствующей ячейки) и сохранить значения этих эталонных байтов. Затем перед особо критичными вызовами нужно лишь сравнивать первые байты процедур с эталонным, и, если обнаружится несоответствие, восстанавливать их. Сам факт того, что процедура начинается с опкода 0CCh говорит о том, что на эту процедуру поставлена точка останова, что может побудить программу предпринять некоторые действия по самозащите. Если учитывать, что многие процедуры начинаются стандартной последовательностью команд push ebp; mov ebp, esp (в шестнадцатиричном редакторе эти команды выглядят как последовательность 55 8B EC), то «действия по самозащите» могут быть простой записью в первые три байта процедуры той самой стандартной последовательности 55 8B EC. После этой операции точка останова, разумеется, исчезнет. Разумеется, выявить защиту от брейкпойнтов, основанную на проверке содержимого неких адресов в памяти, не слишком сложно – нужно лишь поставить аппаратную точку останова на чтение/запись первого байта функции и посмотреть, где этот «капкан на защиту» сработает.

Другой способ постановки бряков на импортированные из DLL функции основан на том, что вызов импортированной функции почти всегда выполняется не напрямую, а через «переходник». На практике вызовы через «переходник» обычно выполняются одним из двух способов.

Первый способ:

Код (Text):
  1.  
  2. call <переходник_к_MyFunc> ; Вызов функции API
  3. переходник_к_MyFunc: jmp MyFunc

(этот способ вызова функций наиболее распространен; переходники вида «jmp истинный_адрес_функции» обычно собраны в конце программы)

Второй способ:

mov edi, dword ptr ds:[элемент_в_таблице]

Код (Text):
  1.  
  2. mov edi, dword ptr ds:[элемент_в_таблице]
  3. call edi
  4. элемент_в_таблице: dd <истинный_адрес_функции_MyFunc>

 (данная техника вызова функций обычно встречается в продуктах Microsoft)

Идея заключается в том, что в первом случае точку останова можно поставить не на первый байт функции, а на переходник, через который вызывается эта функция. В первом случае это будет обычный BPX на адрес команды jmp MyFunc, во втором случае придется прибегнуть к аппаратной точке останова на чтение двойного слова по адресу «элемент_в_таблице». Поскольку это не будут брейкпойнты на функцию в прямом смысле слова, этот метод имеет одно существенное ограничение: если нужная функция вызывается не через «переходник», а непосредственно по значению ее адреса (получаемому, например, при помощи GetProcAddress), то такой брейкпойнт, понятное дело, не сработает. Разумеется, также существует возможность, что программа попытается проверить целостность «переходников», но как такие попытки обнаруживать, Вы уже знаете.

 

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

Код (Text):
  1.  
  2. push <адрес возврата>
  3. jmp <адрес процедуры>

или

Код (Text):
  1.  
push <адрес возврата> push <адрес процедуры> ret

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

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

Код (Text):
  1.  
  2. push <адрес возврата>
  3. push ebp
  4. mov ebp, esp
  5. jmp MyProc+3 ; (1)
  6.  
  7. MyProc:
  8. push ebp
  9. mov ebp,esp
  10. … ; При вызове процедуры в точке (1) будет выполнен переход в эту точку

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

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

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

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

По традиции, для начала разберемся, что эти следы из себя представляют. Представьте себе следующую широко распространенную ситуацию: A=>B=>C, где «A=>B» расшифровывается как «процедура A вызывает процедуру B». При вызове процедуры B стандартными средствами, т.е. командой CALL, в момент начала исполнения процедуры B на вершине стека будет лежать адрес возврата из процедуры B в процедуру A. Аналогичный процесс происходит при вызове процедуры C процедурой B. Получается, что если мы поставим брейкпойнт на точку входа в процедуру C, мы в этот момент сможем наблюдать в стеке следующую картину (вершина стека - вверху):

  • Адрес возврата из процедуры C в процедуру B
  • Адрес возврата из процедуры B в процедуру A

Немного усложним картину, и допустим, что в процедуры B и C передаются некие параметры (порядок передачи параметров для нас в данном примере несущественен). Стек в этом случае будет выглядеть следующим образом:

  • Адрес возврата из процедуры C в процедуру B
  • Параметры, переданные в процедуру C
  • Адрес возврата из процедуры B в процедуру A
  • Параметры, переданные в процедуру B

На практике процедуры обычно занимаются чем-то более сложным, чем простой вызов других процедур с параметрами, а потому довольно часто резервируют на стеке место под локальные переменные. Допустим, что процедуры A и B используют локальные переменные, место под которые выделяется на том же стеке, и посмотрим, что после этого будет твориться в стеке:

  • Адрес возврата из процедуры C в процедуру B
  • Параметры, переданные в процедуру C
  • Область локальных переменных процедуры B
  • Адрес возврата из процедуры B в процедуру A
  • Параметры, переданные в процедуру B
  • Область локальных переменных процедуры A

А теперь представим, что автор защиты на этапе B=>С подменил адрес возврата из процедуры С в процедуру B своим собственным значением, и возврат теперь происходит не в B, а некую процедуру D. Что от этого изменится? Да только одна, самая верхняя строчка! А вот адрес возврата в процедуру A, локальные переменные и параметры вызова какими были, такими и останутся, и это можно использовать в качестве зацепки, позволяющей выявить все этапы пути от процедуры A к процедуре C. Другое дело, что информация, лежащая в стеке, никак не структурирована, поэтому Вам придется самому угадывать, что там – локальные переменные, что – параметры вызовов, а что – адреса возврата. И хотя процесс проверки этих догадок может быть весьма трудоемким, лучше иметь хотя бы такую беспорядочную информацию, чем не иметь никакой. Иногда бывает полезно посмотреть, что находится выше вершины стека – там нередко тоже удается обнаружить следы деятельности процедур, отработавших перед тем, как мы остановили программу.

© CyberManiac

0 1.043
archive

archive
New Member

Регистрация:
27 фев 2017
Публикаций:
532