Введение.

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

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

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

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

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

Эти взгляды характерны не только для пользователей, но и для хакеров. Действительно, зачем что-то ломать, если серийный номер и так известен! Однако встречаются недобросовестные пираты (и такое бывает!), которые распространяют программы без сообщения серийных номеров. Вот тут-то и приходится браться за отладчик!

Рассмотрим простейший пример (file://CD:/SRC/BREAK00). Это программа под win32, использующая библиотеку MFC. Под MS-DOS используются в целом те же принципы защиты, но с небольшими расхождениями. Иногда я на них буду обращать внимание, иногда нет. Не то что бы взлом ДОСовских приложений перестал быть актуальным, просто местами различия не настолько значительны, что бы стоило отвлекать и запутывать читателя.

Запустим break00.exe на выполнение. Программа просит ввести пароль. Наверное, чтобы его сравнить с введенным, оригинальный пароль должен как-то храниться в программе. Очевидным способом является простое посимвольное сравнение:

        if ((s0=ch)!="KPNC") cout << endl << "Password fail" <<endl;

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

Можно было бы просто просматривать дамп программы в любом hex-вьювере, но это утомительно, особенно в применении к большим файлам. Поэтому воспользуемся утилитой - "текстовым фильтром", которая анализирует файл и записывает все встретившиеся текстовые строки. Рекомендую воспользоваться моей собственной утилитой filter.com.

    ------------> смещение в файле
    ¦    -------> текстовая строка
    ¦    ¦
   24FA:Winit
   250C:MSVCP
   3020:Password OK
   3030:Password fail
   3040:KPNC
   ^^^^^^^^^
   3048:Enter password
   305C:CrackMe
   ^^^^^^^^^^^^
   3067: Try to path code of found valid password
   3094:Fatal Error
   30A0: MFC initialization failed

Обратим внимание на строку, находящуюся по адресу 0x3040. Не правда ли, она могла бы быть паролем? Чаще всего, хотя и не обязательно, искомая строка располагается близко к тексту "введите пароль". Ниже мы видим еще одного "кандидата". Давайте проверим, подойдет ли хотя бы один из них?

 --T-----------------------------------------------------------------T-T-T-¬
 +-+-----------------------------------------------------------------+-+-+-+
 ¦CrackMe01 : Try to path code of found valid password                     ¦
 ¦Enter password : KPNC                                                    ¦
 ¦                                                                         ¦
 ¦Password OK!                                                             ¦
 ¦Press any key to continue                                                ¦
 ¦                                                                         ¦

Несмотря на простоту, данный метод не лишен недостатков. Самый главный из них - ничем не гарантированный результат. Скорее всего, пароля не окажется в открытом виде.

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

Первый шаг. От EXE до CRK

Бесспорно, что среди существующих на сегодняшний день дизассемблеров лучшим является IDA. Особенно он подходит для взлома и изучения защищенных программ. Очевидно, что Break00 не является таковой в полном смысле этого слова. В нем нет ни шифрованного кода, ни "ловушек" для дизассемблеров. SOURCER или любой другой справился бы с этой задачи не хуже. Поэтому окончательный выбор я оставляю за читателем.

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

Достаточно только изучить и понять алгоритм защитного механизма, ответственного за сверку паролей. Остается только найти этот самый механизм. Можно ли это как-то сделать иначе, чем полным анализом всей программы? Разумеется, можно! Для этого нужно найти ссылки на строки "неверный пароль", "пароль ОК", "введите пароль". Ожидается, что защитный механизм будет где-то поблизости. Сами строки находятся в кодовом сегменте. (В старых программах под DOS это правило часто не соблюдалось. В частности, компилятор Турбо Паскаля любил располагать константы непосредственно в кодовом сегменте.)

Для перехода в сегмент данных в IDA нужно в меню "View" выбрать "Segment Windows" и среди перечисленных в появившемся окне отыскать сегмент типа "DATA". Искомые строки бросаются в глаза даже при беглом просмотре. Перевести их в удобочитаемый вид можно, переместив курсор на начало строки и нажав "A" (ASCII).

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

Но IDA позволяет найти перекрестные ссылки и самостоятельно. Для этого нужно нажать Alt-T и ввести адрес интересующий нас строки. Давайте попробуем найти код, который выводит 'Enter password'. Нажимаем Alt-T и вводим "403048" (без кавычек) - это адрес, по которому расположена наша строка. Теперь IDA обычным контекстным поиском будет искать все идентичные вхождения по всему дизассемблированному тексту.

Мы должны быть готовы к тому, что получим множество "ложных" срабатываний (просто констант, или смещений, но в другом сегменте), или ничего не получим вообще. Возможно, адрес строки задается не через константу, а хитрыми математическими вычислениями и манипуляциями. Это, действительно, не самый лучший метод, но неоправданно популярный и рекомендуемый многими руководствами.

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

Цель автора защиты - построить код так, что бы не оставить хакеру никакой избыточной информации о работе последнего, по которой бы его можно было бы вычислить. Данный пример ничего подобного не содержит, и IDA нам показывает следующий фрагмент:

  004010C0                 mov     esi, ds:??6std@@YAAAV?$basic_ostream@...
  ........                 ...     ...
  004010E7                 mov     eax, ds:?cout@std@@3V?$basic_ostream@...
  004010EC                 push    403048h
                           ^^^^^^^^^^^^^^^
  004010F1                 push    eax
  004010F2                 call    esi

Но чем является в данном случае 403048h - смещением или константой? Это можно узнать из прототипа функции basic_ostream<E, T> *tie(basic_ostream<E, T> *str). Пусть читателя не смущает небольшая разница в написании имен. Причина этого в двух словах объяснена быть не может и мне ничего не остается, кроме как отослать интересующихся этим вопросом к библиотеке MSDN. Теперь ее документы не только по-прежнему доступны в он-лайне, но и распространяются вместе с MS VC. Без MSDN и глубоких знаний Win32 говорить в хаке под Windows просто неэтично. Это, конечно, не означает, что каждый кракер обладает глубокими знаниями платформы, под которой он трудится. Большинство защит вскрываются стандартными приемами, которые вовсе не требуют понимания "как это работает". Мой тезка, широко известный среди спектрумистов едва ли десяток лет назад, однажды сказал: "Умение снимать защиту еще не означает умения ее ставить". Это типично для кракера и, как видно, отсутствие умения ставить защиту никак не мешает ему эту защиту ломать. Хакер же не ставит своей целью собственно взлом (т.е. способ любой ценой заставить программу работать), а интересуется именно МЕХАНИЗМОМ "как оно работает". Взлом для него вторичен.

Однако мы отвлеклись. Вернемся к прототипу функции basic_ostream. Компилятор языка Cи заносит в стек все аргументы справа налево. Поэтому 0x403048 - это указатель на строку (*str), которую затем и выводит функция. Таким образом, мы находимся в непосредственной близости от защитного механизма. Сделаем еще один шаг вперед.

  004010D8                 mov     edi, ds:??5std@@YAAAV?$basic_istream@DU?
  ........                 ...     ...
  004010F4                 mov     edx, ds:?cin@std@@3V?$basic_istream@DU?
  004010FA                 lea     ecx, [esp+1Ch]
  004010FE                 push    ecx
  004010FF                 push    edx
  00401100                 call    edi

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

Данный пример взят умышленно сложным для понимания. Вероятно, вы думаете, что буфер расположен по адресу [esp+1Ch]? И что бы сравнить введенную строку с эталонной необходимо передать указатель на [esp+1Ch]? Как бы не так! Коварный оптимизирующий компилятор использовал не регистр EBP, значение которого неизменно на протяжении всей процедуры, а указатель на верхушку стека esp! Занесение параметров в стек приводит к изменению esp, и нет почти никакой возможности предугадать его значение в произвольной точке кода. Приходится отслеживать все операции со стеком и вычислять новое значение esp в уме или с помощью хитрых скриптов для IDA, о которых мы поговорим позднее.

Рассмотрим нижеследующий фрагмент. После того, как [esp+1Ch] указывал на буфер, содержащий введенную строку в стек были переданы два двойных слова (см. выше). Заметим, что стек растет "снизу вверх", т.е. от старших адресов к младшим.

Команда add очищает стек от локальных параметров отработанной функции. Тогда новое значение esp равно -2*4+0x10 = -8; 0х1С - 0х8 = 0x14. Следовательно, теперь уже [esp-0x14] указывает на наш буфер!

  00401102                 add     esp, 10h
  00401105                 lea     eax, [esp+14h]
                         ^^^^^^^^^^^^^^^^^^^^^^
  00401109                 lea     ecx, [esp+10h]
  0040110D                 push    eax
  0040110E                 call    j_??4CString@@QAEABV0@PBD@Z

Обратим внимание на подчеркнутую строку. Насколько же с первого взгляда неочевидно, куда указывает указатель eax! Попутно замечу, что даже сегодня не каждый компилятор способен генерировать такой код, ценность которого заключается практически в экономии всего одного регистра ebp. Но читатель должен быть готов, что со временем этому "научатся" все компиляторы и все операции с локальными переменными придется отслеживать указанным выше образом. К счастью, дизассемблеры не отстают в этом от компиляторов, и уже IDA 3.8b прекрасно справляется с этой задачей. Можно даже совершенно не задумываться о том, что собственно происходит у него "внутри". В этом, в частности, и смысл технического прогресса: новые технологии призваны, в первую очередь, освобождать других от глубокого понимания предмета. Между тем такой подход делает человека очень зависимым от окружающих его инструментов. Без них он становится беззащитным перед дикой природой. Далеко не всегда под руками оказывается последняя версия мощного дизассемблера или другого необходимого инструмента. Обычный программист в такой ситуации просто мычит и беспомощно разводит руками. Хороший хакер не почувствует дискомфорта этой ситуации. Глубокие знания и привычка делать все самому, своими руками, не доверяя машинам, дают те необходимые навыки, которые позволяют совершенно без ничего, в прямом смысле этого слова, ломать программу тем, что имеется в распоряжении, даже если для этого придется дизассемблировать код в уме.

Поэтому, что бы показать, как происходит обращение к локальным переменным я начал объяснение с дизассемблера IDA версии 3.6, который "не умеет" автоматически отслеживать изменение esp. Разумеется, это не должно быть поводом для отказа от его использования. Лично мне ближе старая, проверенная и устойчивая версия, чем неустойчивая и непривычная новая 3.8b. В то же время встроенный язык позволяет неограниченно расширять возможности дизассемблера и включать в него все новые технологии и достижения на свой вкус.

Однако использование последней версии IDA выгодно тем, что позволяет получить символьные имена всех используемых функций в популярных библиотеках по их сигнатурам. Так, в вышеприведенном примере трудно понять, что же делает функция в строке 0х040110E. IDA 3.8 уверенно распознает эту функцию как CString::operator=(char const *). Следовательно, введенный пользователем пароль заносится в переменную типа CString, хранящуюся по адресу [esp+10h]. Логично теперь ожидать процедуру сравнения. Более того, мы можем предположить, что она будет одним из методов CString!

  004010DE                 mov     ebx, ds:_mbscmp
  ........                 ...
  0040110E                 call    j_??4CString@@QAEABV0@PBD@Z
  00401113                 mov     eax, [eax]
  00401115                 push    403040h
  0040111A                 push    eax
  0040111B                 call    ebx
  0040111D                 add     esp, 8
  00401120                 test    eax, eax
  00401122                 mov     eax, ds:?cout@std@@3V?$basic_ostream@DU?..
  00401127                 push    eax
  00401128                 jz      short loc_401144

Функция _mbscmp, как можно догадаться по ее названию, сравнивает строки и имеет очевидный прототип int strcmp( const char *string1, const char *string2 ); в строке 0x0401113 мы получим указатель на новую переменную CString. Но что же тогда представляет собой число 0x403040? Очевидно, что это указатель на эталонную строку. Посмотрим, что находится по указанному смещению:

  00403040 aKpnc           db 'KPNC',0

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

Для этого в очередной раз обратимся к библиотеке MSDN, где узнаем, что функция _mbscmp возвращает false (ноль), если строки идентичны и true - в противном случае. Если бы над кодом не поработал оптимизатор, то можно было бы ожидать непосредственно после CALL-а примерно такую конструкцию:

  CALL xxxx
  TEST EAX,EAX
  JZ   xxxx [JNZ xxx]

Однако оптимизатор расположил команды в несколько ином порядке так, чтобы они выполнялись за меньшее число тактов. К сожалению, в ущерб читабельности. Условный переход находится на четыре команды ниже в строке 0х0401128. TEST EAX,EAX устанавливает флаг Zero в том случае, если EAX == 0. Следовательно, переход JZ выполняется, только когда сравниваемые строки идентичны. Думаю, что читатель с удовольствием сможет удостовериться, что код в ветке loc_401144 выводит "Password OK" и в законченном (а не в демонстрационном) приложении продолжает нормальное выполнение программы.

Давайте подумаем, что будет, если мы заменим условный переход JZ на БЕЗУСЛОВНЫЙ JMP? Тогда, независимо от результатов сравнения, а, следовательно, и от введенной строки программа будет воспринимать любой пароль как правильный!

IDA 3.6 не может записывать отплаченный PE файл, поэтому нам придется этим заняться самостоятельно. Для этого нужно найти в файле тот же самый фрагмент, что мы видим в дизассемблере. HIEW позволяет искать непосредственно ассемблерные инструкции, облегчая взломщикам жизнь. Но мы пойдем другим путем. Гораздо надежнее искать hex-последовательность, которую включает интересуемый нас фрагмент. Для этого переключим IDA в режим показа опкода инструкций.

Строго говоря, теперь нам предстоит выбрать сигнатуру, т.е. по возможности короткую, но уникальную последовательность, которая повторяется в файле только один раз. Разумеется, последовательности jz xxx (0x74 0x1A) скорее всего, окажется недостаточно, поскольку ожидается, что она может встретиться более чем в одном контексте. Практика показывает, что обычно требуется последовательность не менее чем из трех инструкций. Конечно, чем короче файл, тем меньше вероятности ложных срабатываний. Давайте ограничимся всего двумя командами - push eax и jz xxx. Запишем на бумажку или запомним их опкод - 50 74 1A.

Теперь запускаем hiew, переводим его в hex-режим и пытаемся найти эту последовательность. Если все сделано правильно, то мы обнаружим ее по адресу 0x0401127. Удостоверимся, что это действительно единственное вхождение и больше совпадений нет. Если же в файле присутствует более одной строки, то возвращаемся в IDA, и записываем более длинную последовательность. Впрочем, иногда (чем больше опыта, тем чаще) можно определить ложные варианты "на глаз", сравнивая код в этом месте с тем фрагментом, что мы видели в дизассемблере.

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

Так или иначе, но пришло время немного "похулиганить" и изменить ту заветную пару байт, которая мешает нелегальным пользователям получить доступ к программе. А так же всем легальным, но забывшим пароль. Как уже было показано выше, изменение условного перехода на безусловный приведет к тому, что программа будет любой пароль воспринимать как правильный. Опкод команды JMP SHORT - 0xEB. Узнать это можно из руководства Intel по микропроцессорам 80x86. Впрочем, hiew позволяет обойтись и без этого. Достаточно перейти в режим ассемблера и ввести jums с тем же адресом перехода, что и JZ. Сохраняем проделанные изменения и выходим из hiew.

Запустим программу и попробуем ввести любое слово, желательно нормативной лексики, пришедшее нам на ум. Если мы все сделали правильно, то на экране появится заветное "Password OK". Если же программа зависла, значит, мы где-то допустили ошибку. Восстановим программу с резервной копии и повторим все сначала.

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

Подумаем, что будет, если заменить JZ на JNZ? Ветви программы поменяются местами! Теперь, если будет введен неправильный пароль, система воспримет его как истинный, а легальный пользователь, вводя настоящий пароль, с удивлением прочитает сообщение об ошибке. Так ему и надо.

Часто кракеры любят оставлять во взломанной программе свои лозунги или, с позволения сказать, "копирайты". Модификация подобного рода в откомпилированных исполняемых файлах довольно трудна и требует навыков, которыми вряд ли обладает начинающий. Но ведь свою подпись оставить так хочется! Однако для подобной операции можно использовать уже не нужный во взломанной программе фрагмент, выводящий сообщение о неверно набранном пароле. Вспомним, как расположены ветки в исполняемом файле:

                  --------------------------¬
                  ¦ Ввод и сравнение пароля ¦<--¬
                  +-------------------------+   ¦
               ---+ JZ Password_ok          ¦   ¦
               ¦  +-------------------------+   ¦
               ¦  ¦                         ¦   ¦
               ¦  ¦ "НЕВЕРНЫЙ ПАРОЛЬ"       ¦   ¦
               ¦  ¦                         ¦   ¦
               ¦  +-------------------------+   ¦
               ¦  ¦ JMP enter&compare       +----
               ¦  +-------------------------+
               ¦  ¦                         ¦
               L->¦ "ВЕРНЫЙ ПАРОЛЬ"         ¦
                  ¦                         ¦
                  L--------------------------

Что будет, если мы удалим два перехода (один условный, второй - безусловный)? В этом случае последовательно отработают две ветки программы. Чтобы "убить" любую инструкцию достаточно "забить" ее NOP (опкод которой 0x90, а вовсе не 0, как почему-то думают многие начинающие кодокопатели). Обе команды в нашем примере двухбайтовые и поэтому каждую придется заменить двумя инструкциями NOP.

Кажется, мы все сделали правильно, однако "программа выполнила недопустимую операцию и будет закрыта". К сожалению, мы забыли об оптимизирующем компиляторе. Это затрудняет модификацию программы. Но ни в коем случае не делает ее невозможной. Давайте заглянем "под капот" могучей системы Windows и посмотрим, что там творится. Запустим программу еще раз и вместо аварийного закрытия нажмем кнопку "сведения" и получим следующий текст:

   Программа BREAK_X вызвала сбой при обращении к странице памяти
   в модуле MSVCP60.DLL по адресу 015f:780c278d.

Какие разочаровывающе малоинформативные сведения. Разумеется, ошибка никак не связана с MSVCP60.DLL, и указанный адрес, лежащий глубоко в недрах этой библиотеки, нам совершенно ни о чем не говорит. Даже если мы туда рискнем отправиться с отладчиком, то следов причины мы не найдем. В действительности, вызываемой функции передали неверные параметры, которые и привели к возникновению исключительной ситуации. Конечно, это говорит не в пользу фирмы Microsoft, т.к. что это за функция такая, которая не проверяет передаваемые ей аргументы?! С другой стороны, именно сокращением числа таких проверок и вызвано некоторое ускорение Windows 98 по сравнению с ее предшественницей. Но нужно ли нам такое "ускорение"? Я бы твердо ответил - "Нет, не нужно". Жаль только, что Билл Гейтс так и не услышит эти слова...

Однако мы опять отвлеклись. Как же нам проникнуть внутрь Windows и выяснить, что там у нее не в порядке? В этом нам поможет другой продукт фирмы Microsoft - MS Visual C. Будучи установленным в систему он делает доступной кнопку "Отладка" в окне аварийного завершения. Теперь мы можем не только закрыть некорректно работающее приложение, но и разобраться, в чем причина сбоя.

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

  std::basic_ostream<char,std::char_traits<char> >::opfx(std::basic_ostre...
  std::basic_ostream<char,std::char_traits<char> >::put(std::basic_ostrea...
  std::endl(std::basic_ostream<char,std::char_traits<char> > & {...})
  BREAK_X! 0040114a()
  CThreadSlotData::SetValue(CThreadSlotData * const 0x00000000, int 4,....

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

  00401142   nop
  00401143   nop
  00401144   call        dword ptr ds:[402038h]
  0040114A   push        403020h

Узнаете окружающий код? Да-да, это то самое место, где мы его модифицировали. Но в чем причина ошибки? Обратите внимание, что перед вызовом функции в строке 0x0401144 не были переданы параметры! Куда же они могли подеваться? А... Это хитрый оптимизирующий компилятор расположил их так, что бы они оказывались в стеке только в том случае, если эта ветка получает управление. Вернемся к оригинальной копии, что бы подтвердить наше предположение.

  401122                 mov     eax, ds:?cout@std@@3V?$basic_ostream...
  401127                 push    eax
  401128                 jz      short loc_401144

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

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

Изменив всего один байт 0x74 на 0xEB мы грязно взломали программу. Кракер на этом остановится, но хакер пойдет дальше. Почему "грязно"? Программа по-прежнему спрашивает пароль. И хотя не имеет значения какой, все же это может сильно раздражать, да и просто это не аккуратно. Давайте модифицируем программу так, чтобы она вообще не отвлекала нас запросом пароля.

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

  4010D8                 mov     edi, ds:??5std@@YAAAV?$basic_istream@DU?...
  ......                 ...     ...
  4010FA                 lea     ecx, [esp+1Ch]
  4010FE                 push    ecx
  4010FF                 push    edx
  401100                 call    edi
  401102                 add     esp, 10h

Стек очищается командой ADD ESP,10h. Функция не изменяет его. Поэтому удаление этой функции нам ничем не грозит, и мы без последствий можем "забить" ее командами NOP. Кроме этого, можно удалить две команды push (но тогда, соответственно, изменить add esp,10h на add esp,0x8) но это вопрос стиля. Кому-то так может показаться красивее, а другой на захочет выполнять бесполезную работу.

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

Что же еще можно улучшить? Надпись Enter password: по-прежнему выводится и выглядит несколько небрежной кляксой на фоне опрятного взлома. Отключим ее? Заметим, что это можно сделать, изменив всего один байт - поставить в начало выводимый строки завершающий символ 0. Это не потребует изменения кода, что безопаснее. Ну а что, если мы вместо 'Enter password' запишем свой копирайт? Должны же пользователи знать, какого добродетеля им следует благодарить! Рассмотрим подробно эту "простую" операцию, ибо она далеко не такая простая, какой кажется на первый взгляд. Было бы не плохо, будь строка 'enter password' раза в два подлиннее. А в таком ограниченном объеме мало, что можно записать. На самом деле существующие ограничения легко обойти. Рассмотрим несколько очевидных вариантов, как это можно сделать.

  0403020: 50 61 73 73-77 6F 72 64-20 4F 4B 21-00 00 00 00  Password OK!
  0403030: 50 61 73 73-77 6F 72 64-20 66 61 69-6C 00 00 00  Password fail
  0403040: 4B 50 4E 43-00 00 00 00-45 6E 74 65-72 20 70 61  KPNC    Enter pa
  0403050: 73 73 77 6F-72 64 20 3A-20 00 00 00-43 72 61 63  ssword :    Crac
  0403060: 6B 4D 65 30-31 20 3A 20-54 72 79 20-74 6F 20 70  kMe01 : Try to p
  0403070: 61 74 68 20-63 6F 64 65-20 6F 66 20-66 6F 75 6E  ath code of foun
  0403080: 64 20 76 61-6C 69 64 20-70 61 73 73-77 6F 72 64  d valid password
  0403090: 00 00 00 00-46 61 74 61-6C 20 45 72-72 6F 72 3A      Fatal Error:
  04030A0: 20 4D 46 43-20 69 6E 69-74 69 61 6C-69 7A 61 74   MFC initializat
  04030B0: 69 6F 6E 20-66 61 69 6C-65 64 00 00-00 00 00 00  ion failed

Во взломанной программе строки 'Password fail!' и 'KPNC' уже не нужны. И мы их можем использовать для своих нужд. Для этого нужно изменить указатель на выводимую строку. Как мы помним, он расположен по адресу 0х04010EC:

  004010EC                 push    403048h

изменим смещение 0x403048 на 0x0403030. Тогда нам будет доступна вся область до 0х403059 (т.е. до начала строки 'CrackMe....'). Только не забудьте конец строки отметить завершающим нулем.

С другой стороны, если обратите внимание, в сегменте данных еще много свободного места (на этом дампе оно не показано). Если уж мы изменили смещение выводимой строки, почему бы тогда не расположить необходимую нам строку в любой свободной области и не установить на нее указатель?

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

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

Для начала нужно установить, какие именно байты были изменены. Для этого нам вновь потребуется оригинальная копия и какой-нибудь сравниватель файлов. Наиболее популярными на сегодняшний день являются c2u by Professor Nimnul и MakeCrk by Doctor Stein's labs. Первый из них предпочтительнее, т.к. не только более точно придерживается наиболее популярного "стандарта" (если можно так сказать), но и позволяет генерировать расширенный xck-формат.

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

Теперь нам потребуется другая утилита, действие которой будет прямо противоположно - используя crk-файл, изменить эти самые байты в оригинальной программе. Таких утилит на сегодняшний день очень много. К сожалению, это не лучшим образом сказывается на их совместимости с различными crk-форматами. Самые известными из них, скорее всего, будут cra386 by Professor и pcracker by Doctor Stein's labs.

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

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

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

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

Как победить хеш

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

В главе, посвященной динамической шифровке, я впервые использовал в демонстрационной программе хеш-функцию для сокрытия пароля. Действительно, пусть у нас имеется функция f(password) = hashe, которая в первом приближении необратима. Тогда значение hashe не дает никакой информации о самом пароле!

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

   if ((s0=ch)!="KPNC")       cout  << "Password fail" << endl;
   if (hashe(&pasw[0])!=0x77) cout  << "Password fail" << endl;

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

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

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

Убедимся в этом в очередной раз, удалив типовую защиту из предлагаемого примера. Попробуем для начала найти с помощью filter.com все текстовые строки, входящие в исполняемый файл. С удивлением рассмотрев протокол, мы не обнаружим там ничего похожего на то, что программа выводит на экран. Неужели файл зашифрован? Не будем спешить с выводами. Используем hiew для более подробного изучения файла. Отсутствие строк в сегменте данных наводит нас на мысль, что они могут находится в ресурсах. Для подтверждения этой гипотезы просмотрим содержимое ресурсов файла.

Как в этом случае найти код, выводящий эти строки? Заглянув в SDK, мы узнаем, что загрузить строку из ресурса можно функцией LoadStringA для ANSI и LoadStringW для UNICODE. Чтобы понять, какая же из двух функций используется в нашей программе, изучим таблицу импорта функций исполняемого файла. Для этой цели подойдет утилита dumpbin или любая другая.

Странно, но приложение не импортирует функции LoadString, более того, не импортирует ни одной функции из USER32! Как же при этом оно может работать? Рассмотрим иерархию импорта в 'Dependency Walker'.

На самом деле вызывается функция LoadString - метод класса CString, которая уже, в свою очередь, вызывает LoadStringA Win32 API. Посмотрим, какие функции MFC42.DLL импортирует наша "подопытная" программа.

  MFC42.DLL
            40200C Import Address Table
            402140 Import Name Table
                 0 time date stamp
                 0 Index of first forwarder reference
                  Ordinal   815
                  Ordinal   561
                  Ordinal   800
                  Ordinal  4160
                  Ordinal   540
                  Ordinal  1575

Какая жалость! Символьная информация недоступна и известен только ординал. Можно ли как-то определить, какая из этих функций LoadString? Возможно, нам будет доступна символьная информация непосредственно в MFC42.DLL? Увы, там тоже не содержится ничего интересного, кроме ординалов. Но как же линкер справляется с этой ситуацией? Заглянем в MFC42.map и попробуем найти строку по контексту 'LoadString'

  0001:00003042       ?LoadStringA@CString@@QAEHI@Z 5f404042 f

Замечательно! Но как же нам теперь получить ее ординал? Для этого уточним, что же означает '0001:00003042'.

  Preferred load address is 5f400000
   Start         Length     Name                   Class
   0001:00000000 00099250H .text                   CODE

Следовательно, 0x3042 - это смещение относительно секции .text. Давайте воспользуемся утилитой hiew, чтобы посмотреть, что там находится. Для этого сначала вызовем таблицу объектов, выберем '.text' и к полученному смещению прибавим 0x3042. Переведем hiew в режим дизассемблера.

  5F404042: 55                           push      ebp
  5F404043: 8BEC                         mov       ebp,esp
  5F404045: 81EC04010000                 sub       esp,000000104 ;"
  5F40404B: 56                           push      esi

То, что мы сейчас видим до боли напоминает пролог функции. Да почему, собственно, напоминает?! Это и есть точка входа в функцию LoadStringA@CString@@QAEHI@! Разумеется, теперь нетрудно в списке ординалов найти такой, чей адрес совпадает с полученным. Однако, прежде чем приступить к поиску уточним, что мы, собственно, намереваемся искать. Вычтем из полученного адреса рекомендуемый (0x5F404042 - 0x5f400000 = 0x4042) - именно это значение будет присутствовать в таблице экспорта, а не 0x5F404042, как можно было бы подумать с первого взгляда.

Используем ранее полученный список экспорта функций MFC42.dll. Просматривать вручную двухмегабайтовый файл рапорта было бы чрезвычайно утомительно, поэтому воспользуемся контекстным поиском.

   4160      00004042 [NONAME]

Оказывается, ординал этой функции 4160 (0x1040). Не кажется ли вам, что это число мы уже где-то видели? Вспомним таблицу импорта crack02.exe:

  MFC42.DLL
             40200C Import Address Table
             402140 Import Name Table
                  0 time date stamp
                  0 Index of first forwarder reference
                   Ordinal   815
                   Ordinal   561
                   Ordinal   800
                   Ordinal  4160
                   ^^^^^^^^^^^^^
                   Ordinal   540
                   Ordinal  1575

Круг замкнулся. Мы выполнили поставленную перед собой задачу. Но можно ли было поступить как-то иначе? Неужели никто не догадался это автоматизировать?

Разумеется, догадался. С этим справляется любой хороший дизассемблер. Например, IDA. Но последний надежно скрывает под "капотом" все эти операции. Используя инструментарий подобного класса можно даже не задумываться, как это все происходит.

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

К счастью у нас под рукой IDA и мы избежим кропотливой и трудоемкой работы. Убедимся, что он правильно извлек символьную информацию об импортируемых функциях. Для этого перейдем в сегмент "_rdata":

  ; Imports from MFC42.DLL
    ??1CWinApp@@UAE@XZ
    ??0CWinApp@@QAE@PBD@Z
    ??1CString@@QAE@XZ
    ?LoadStringA@CString@@QAEHI@Z
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    ??0CString@@QAE@XZ
    ?AfxWinInit@@YGHPAUHINSTANCE__@@0PADH@Z

Переместим курсор на LoadStringA@String, чтобы узнать, какой код ее вызывает. После недолгих путешествий мы должны увидеть приблизительно следующее:

  0040119F                 push    1
  004011A1                 lea     ecx, [esp+0Ch]
  004011A5                 mov     [esp+2Ch], edi
  004011A9                 call    j_?LoadStringA@CString@@QAEHI@Z

Похоже, что функция принимает всего один параметр. Уточним это с помощью MSDN - BOOL LoadString( UINT nID ). Передаваемый параметр представляет собой идентификатор ресурса. Используем любой редактор ресурсов (например, интегрированный в MS VC), чтобы увидеть связанную с ним строку. Нетрудно убедиться, что это та самая строка, которую программа первой выводит на экран. Следовательно, мы находимся в самом начале защитного механизма.

Продолжим наши исследования дальше до тех пор, пока не обнаружим уже знакомую нам функцию ввода.

  004011EA                 lea     edx, [esp+14h]
  004011EE                 push    edx
  004011EF                 push    eax
  004011F0                 call    ds:??5std@@YAAAV?$basic_istream@DU...

Вычислим новое значение указателя после засылки двух агрументов в стек. Оно будет равно 14h+2*4 = 0x1C. Тогда нам становится понятен смысл следующего фрагмента:

  04011F6     lea     ecx, [esp+1Ch]   ; указатель на введеный пароль
  04011FA     push    ecx
  04011FB     call    sub_4010E0       ; (1)
  0401200     add     esp, 14h         ; 0x1C  - 0x14 + 4 = 0xC
  0401203     lea     edx, [esp+0Ch]   ; указатель на введенный пароль
  0401207     push    eax              ; агрумент для функции (3)
  0401208     push    edx
  0401209     call    sub_4010E0       ; (2)
  040120E     add     esp, 4
  0401211     push    eax
  0401212     call    sub_4010C0       ; (3)
  0401217     add     esp, 8
  040121A     cmp     ax, 1F8h         ; Сравниваем ответ
  040121E     jz      short loc_401224 ; !ВОТ ИСКОМЫЙ ПЕРЕХОД!

Довольно витиеватый алгоритм, задействовавший три процедуры. Даже не интересуясь, что делает каждая из них, мы с уверенностью можем сказать, что тот условный переход в строку 0x040121E и является той "высшей инстанцией", которая выносит окончательный вердикт. Замена его на безусловный приводит к тому, что, независимо от результата работы хеш-функции, управление будет получать "правая" ветка программы.

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

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

   while (true)
   {
      if (!SecretString[pTxt]) break;
      if (Password[pPsw]<0x20) pPsw = 0;
      SecretString[pTxt++] = SecretString[pTxt] ^ Password[pPsw++];
   }

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

Однако, можно включать зашифрованные данные прямо в исходный текст программы приблизительно как:

   char SecretString[] = "\x1F\x38\x2B\x63\x49\x4E\x38\x24\x6E\x2A\x58\x0B"

Чтобы получить подобную строку, нужно воспользоваться специально написанным для этой цели шифровальщиком. Например таким, как file: //CD:/SRC/CRACK03/Crypt.asm.

       MOV     DI,offset Text-1
  Reset:
       LEA     SI,Password
  Crypt:
       LODSB
       OR      AL,AL
       JZ      Reset
       INC     DI
       XOR     [DI],AL
       JMP     Crypt

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

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

   401276                 call    sub_401120
   40127B                 add     esp, 8
   40127E                 cmp     ax, 1F8h
   401282                 jz      short loc_401291

Попробуем заменить условный переход на jmp short loc_401291, предполагая, что независимо от введенного пароля, программа будет корректно работать. Посмотрим, что из этого получилось:

   ¦Crack Me 03
   ¦Enter password : crack
   ¦ 
   ¦|JJ 
   ¦

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

Теперь заменой одного-двух байт программу никак не взломаешь. Нужно разобраться, как она манипулирует паролем и как его можно найти. На эти вопросы невозможно ответить без глубокого и вдумчивого анализа механизма защиты и процедуры расшифровки. Цель хакера - сократить эти усилия до минимума. Давайте подумаем, с чего начать изучение программы. С механизма проверки пароля? На самом деле, вовсе нет. Точный механизм проверки нас пока не интересует. А вот как используется пароль - это уже интересно. Чтобы это выяснить, продолжим анализ со строки 0x0401291, на которую ссылается условный переход.

   00401291 loc_401291:                             ;
   00401291                 lea     eax, [esp+10h]
   00401295                 lea     ecx, [esp+0Ch]
   00401299                 push    eax
   0040129A                 push    ecx
   0040129B                 call    sub_4010C0

Мы знаем, что [esp+10h] указывает на начало буфера, содержащего введенный пароль. Но что тогда [esp+0Ch]? Похоже, что это адрес буфера для возвращения результата работы процедуры. Для подтверждения этого предположения заглянем внутрь последней.

   004010C0                 sub     esp, 28h
   004010C3                 push    esi
   004010C4                 push    edi
   004010C5                 mov     ecx, 9
   004010CA                 mov     esi, 403020h
   004010CF                 lea     edi, [esp+0Ch]
   004010D3                 mov     dword ptr [esp+8], 0
   004010DB                 rep movsd
   004010DD                 mov     edi, [esp+38h]
   004010E1                 xor     eax, eax
   004010E3                 lea     esi, [esp+0Ch]

Что такое 0x0403020 - константа или смещение? Посмотрим: esi как указатель используется командой movsd, следовательно, это смещение. Посмотрим, что расположено по этому адресу:

   a8Cin8NX?8Cne_7 db '8+cIN8$n*X',0Bh,'?8+cNE.=7cDMk$&&',0Bh,'L$?*c',0

Нечто абсолютно нечитабельное. Однако завершающий нуль наводит на мысль, что это может быть строкой. Команда "mov dword ptr [esp+8], 0" еще больше укрепляет нашу уверенность. Действительно, ноль в конце строки не случайность, а часть структуры. С другой стороны, зная особенности используемого компилятора, нетрудно заметить, что рассматриваемый код декомпилируется в обычную конструкцию char MyString[]="It's my string". Но почему же эта строка так странно выглядит? Быть может, она зашифрована? Эту мысль подтверждает установка регистра edi на начало пароля. Наступает самый волнующий момент - мы переходим к изучению криптоалгоритма. Если он окажется недостаточно стойким, то можно будет подобрать подходящий пароль. Обратим внимание на следующий фрагмент кода:

   004010F5             mov dl, [eax+edi]
   004010F8             xor dl, cl
   004010FA             mov [esi], dl
   004010FC             inc esi
   004010FD             inc eax
   004010FE             jmp short loc_4010E7

Попробуем записать его в более удобочитаемом виде, чтобы было легче отождествить алгоритм:

   SecretString[pTxt++] = SecretString[pTxt] ^ Password[pPsw++];

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

   04011F6     lea     ecx, [esp+1Ch]   ; указатель на введеный пароль
   04011FA     push    ecx
   04011FB     call    sub_4010E0       ; (1)
   0401200     add     esp, 14h         ; 0x1C  - 0x14 + 4 = 0xC
   0401203     lea     edx, [esp+0Ch]   ; указатель на введенный пароль
   0401207     push    eax              ; агрумент для функции (3)
   0401208     push    edx
   0401209     call    sub_4010E0       ; (2)
   040120E     add     esp, 4
   0401211     push    eax
   0401212     call    sub_4010C0       ; (3)
   0401217     add     esp, 8
   040121A     cmp     ax, 1F8h         ; Сравниваем ответ
   040121E     jz      short loc_401224 ; !ВОТ ИСКОМЫЙ ПЕРЕХОД!

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

   if (f(f1(&pasw[0]),f1(&pasw[0]))== 0x1F8) ....

Как работает функция f? Изучим следующий фрагмент:

   00401120 sub_401120      proc near
   00401120
   00401120                 mov     eax, [esp+4]
   00401124                 mov     ecx, [esp+8]
   00401128                 and     eax, 0FFh
   0040112D                 and     ecx, 0FFh
   00401133                 imul    eax, ecx
   00401136                 sar     eax, 7
   00401139                 retn
   00401139 sub_401120      endp

Она умножает аргументы друг на друга и берет старшие 9 бит (0x10-0x7). Это хорошая хеш-функция. Для ее обращения потребуется разлагать числа на множители, что нельзя эффективно реализовать. С другой стороны, прямое ее вычисление очень быстрое, что упрощает перебор паролей. Однако, обратим внимание на то, что на самом деле ее аргументы РАВНЫ. Таким образом, обращение функции сводится к элементарному вычислению квадратного корня. После чего останется перебрать 2^7 = 0x80 (128) вариантов (т.к. эти биты были отброшены хеш-функцией). Это смехотворное число вселяет в нас уверенность, что и пароль мы сможем найти очень быстро. Но не будем спешить. Необходимо реверсировать еще одну хеш-функцию. Посмотрим, что за сюрприз приготовил нам автор на этот раз:

   00401152 loc_401152:
   00401152                 mov     al, [esi]
   00401154                 cmp     al, 20h
   00401156                 jl      short loc_40117F
   00401158                 mov     cl, [esi-1]
   0040115B                 mov     [esp+14h], al
   0040115F                 mov     edx, [esp+14h]
   00401163                 mov     [esp+0Ch], cl
   00401167                 mov     eax, [esp+0Ch]
   0040116B                 push    edx
   0040116C                 push    eax
   0040116D                 call    sub_401120
   00401172                 lea     ecx, [edi+esi]
   00401175                 add     esp, 8
   00401178                 shl     eax, cl
   0040117A                 or      ebx, eax
   0040117C                 inc     esi
   0040117D                 jmp     short loc_401152

Чтобы разобраться в алгоритме этого непонятного кода попробуем его построчно перевести на Си-подобный язык.

   00401152  while (true) {                // Цикл
   00401152  char _AL = String[idx+1];     // Берем один  символ
   00401154  if  (_AL < 0x20) break;       // Это конец строки?
   00401158  char _CL = String[idx];       // Берем другой смвол
   0040115B  chat _s1 = _AL;               // Сохраняем _AL в стеке
   0040115F  (DWORD) _EDX = _s1;           // Расширяем до DWORD
   00401163  char _s2 = _CL;               // Сохраняем в стеке
   00401167  (DWORD) _EAX = _s2;           // Расширяем до DWORD
   0040116D  _EAX=f(_EAX,_EDX);            // Уже знакомая нам функция!
   00401172  CHAR _CL = idx;               // см. объяснения ниже
   00401178  _EAX = _EAX << _CL;           // сдвигаем влево
   0040117A  DWORD _EBX = _EBX | _EAX;     // накапливаем единичные биты
   0040117C   idx++
   0040117D  }

Весь код понятен, кроме странной и совершенно непонятной на первый взгляд операции "00401172 lea ecx, [edi+esi]". Поскольку ESI - это однозначно указатель на текущий символ, то что же тогда edi?

   00401141                 mov     eax, [esp+8]
   00401148                 or      edi, 0FFFFFFFFh
   0040114B                 xor     ebx, ebx
   0040114D                 lea     esi, [eax+1]
   00401150                 sub     edi, eax

Этот код наглядно показывает, какие перлы иногда может выдавать оптимизатор. Попробуем разобраться что же было на этом месте в исходном коде:

    edi = (or edi, 0FFFFFFFFh) = -1; тогда esi = (lea esi, [eax+1]) == &String+1; и edi = (sub     edi, eax) == -1-(&String) = -1 - &String. Поэтому ecx = (lea ecx, [edi+esi]) == -&String - 1      + &String+idx + 1 == idx!

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

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

Но как бы то ни было, а приблизительный исходный текст программы восстановить не составляет труда. Вероятно, в оригинале он выглядел так:

   while (true) {
      if (String[idx+1]<0x20)
         break;
      x1 = x1 | (f (String[idx],String[idx+1]) << idx++);
   } ;

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

Я настоятельно рекомендую попытаться реализовать эту задачу самостоятельно и только в крайнем случае прибегать к готовому решению (file://CD/SRC/CRACK_3).

Собственно алгоритм перебора паролей достаточно несложен и в не самой лучшей реализации может выглядеть так:

   while (1)
   {
     int idx=0;
     while ((Password[idx++]++)>'z') Password[idx-1]='!';
     if (mult(hashe(&Password[0]),hashe(&Password[0]))==0х1F8)
        printf ("Password - %s \n",Password);
   }

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

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

Не будет исключением и этот случай. Наш простой переборщик начнет "плеваться" паролями, чья хеш-сумма в точности равна 0x1F8, но настоящими паролями все они, разумеется, не являются. Их много, очень много, очень-очень много... Похоже, дальнейший перебор не имеет никакого смысла и его придется прекратить. Почему? Рассмотрим фрагмент протокола:

     Password - yuO
     Password - xvO
     Password - uwO
     Password - wwO
     Password - rxO
     Password - vxO
     Password - qyO
     Password - uyO
     Password - nzO
     Password - pzO

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

Это пик торжества разработчика защиты. Использовав ПЛОХУЮ хеш функцию со слабым рассеянием он обрубил нам все пути ко взлому своего приложения. Бесполезно даже обращение хеш-функции, ибо оно ничего не даст. Мы получим те же "левые" пароли, может, чуточку быстрее.

Конечно, существует вероятность, что пользователь введет неправильный пароль, который система воспримет как достоверный, но скажет "мяу" и откажет в работе. Введем наугад любой из вероятных паролей, например, 'yuO'. Вместо сообщения об ошибке на экране появится мусор некорректно работающей программы. Однако, мы заранее ожидали этого результата. А какова вероятность того, что такое произойдет от неумышленной ошибки пользователя? Расчеты показывают, что небольшая. Практика действительно подтверждает низкую вероятность этого события.

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

Какой непредсказуемый поворот событий! И очередная, с виду не заметная лазейка разрушает всю воздвигнутую систему защиты. Строго говоря, даже не требуется атаки на шифротекст. Необходим лишь метод автоматического контроля, позволяющий отсеять максимально возможное число "ложных" паролей. Для этого используем тот факт, что оригинальный текст (а в нашем примере это должен быть какой-то лозунг или поздравление) с большой вероятностью содержит '_a_', '_of_', '_is_' и т.д. Если в расшифрованном тексте хоть одно из этих слов присутствует, и нет ни одного символа, выходящего за интервал '!' - 'z', то это неплохой кандидат на настоящий пароль. Предложенный метод хотя и является крайне медленным, но, похоже, единственно возможным. Дополним существующий переборщик еще парой строк:

   if (mult(hashe(&Password[0]),hashe(&Password[0]))==0х1F8)
   {
    s0=Protect(&Password[0]);
    if (s0.Find(" is ")!=-1) printf ("Password - %s  - %s\n",Password,s0);
   }

Приведенная реализация не является образцом для подражания и написана исключительно с целью лучшей демонстрации материала и никак не предназначена для конечного использования.

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

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

   Password - KkEC++ - TSn besO+is tSn eneVr of Oce goTo

Теперь уже совсем не трудно проанализировать полученный текст и найти настоящий пароль. С большой вероятностью исходный текст можно просто угадать! Или, по крайне мере, продолжить словарный перебор. Благо теперь это не трудно. Предположим, что 'TSn' это искаженное 'The', следовательно, ожидаемый пароль 'KPNC++', а вся фраза читается как:

   'The best is the enemy of the good'

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

Разработчики защит действительно часто очень наивны и ленивы в этом отношении.

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

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

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

Парадоксально, но этот способ крайне редко применяется разработчиками. Из всех программ, защищенных подобным образом, я на вскидку могу вспомнить только FDR 2,1, в котором фрагмент кода, отвечающего за регистрацию расшифровывался "магическим словом" 'Pink Floyd'. Обычно применяют более наивные защитные механизмы, которым посвящена следующая глава.


Заказ книги по почте:    solon.pub@relcom.ru или Rem.Serv@relcom.ru

Телефоны:    (095) 252-72-79 или (095) 254-44-10