Введение.
Логические (программные) защиты основываются на том предположении, что код программы не будет изучен и (или) изменен. В этом случае приложение рассматривается как "черный ящик", на вход которого подается некоторая ключевая информация. Это может быть серийный номер, ключевой диск, да все что придумает автор. Грубо все защиты можно разделить на две категории:
Любопытно, что первая категория защит в основном полагается на законодательство и законопослушность пользователей. Действительно, что помешает легальному пользователю "поделиться" паролем или сообщить серийный номер всем желающим? Конечно, подобное действие можно квалифицировать как "пиратство" и наказать нарушителя. Но равно так же можно наказать за распространение любого ПО, охраняемого авторским правом, никак не зависимо от наличия или отсутствия защиты на нем.
Общеизвестно, что исполнительные органы по части пиратства относятся очень лояльно к преступникам. Нелегальное ПО можно свободно купить в любом городе в центральных магазинах и на радиорынках.
В этих условиях "спасение утопающих - дело рук самих утопающих". Разработчики ПО используют специальные методы, препятствующие нелегальному тиражированию своей продукции. Самые распространенные сегодня защиты - это пароли и серийные номера. Как уже было отмечено выше, воистину наивно полагаться на то, что это затруднит копирование. Серийные номера содержатся в файлах 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 ¦ ¦ ¦
Несмотря на простоту, данный метод не лишен недостатков. Самый главный из них - ничем не гарантированный результат. Скорее всего, пароля не окажется в открытом виде.
Более надежным способом (но, увы, и более трудоемким) является дизассемблирование программы и анализ алгоритма защиты. Это трудоемкая и кропотливая работа, требующая не только знаний ассемблера, но и усидчивости и немного интуиции.
Бесспорно, что среди существующих на сегодняшний день дизассемблеров лучшим является 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