CyberManiacТеоретические основы крэкинга
Основной идеей дзен - крэкинга (именно это название широко используется на сайте Fravia для обозначения той системы крэкинга, о которой я рассказываю) стало: «я не знаю, как это работает, но я могу это сломать». Разумеется, речь не идет об абсолютном незнании того, как функционирует программа – знание команд ассемблера, способов передачи параметров в функции и процедуры, назначения системных вызовов ОС, особенностей генерации кода определенных компиляторов и многого другого, несомненно, является обязательным. Более того, это основы, без которых любое изучение внутренностей программ в принципе невозможно – нельзя получить информацию из книги, не понимая языка, на котором она написана. «Не знаю, как работает» следует понимать в том смысле, что очень часто для успешного взлома программы совершенно необязательно проводить доскональный анализ всех защитных процедур. Иметь возможность сказать: «я знаю, для чего нужен каждый байт в этой программе» - это, конечно, хорошо, но на практике вполне успешно работает модель «черного ящика», когда нам известно назначение отдельных процедур, взаимосвязь между ними и то, какие эффекты вызывает передача тех или иных параметров на входы «черного ящика». В крэкинге есть два пути. Первый – путь глубокого анализа, изучения и достижения понимания того, как работает программа. Этот путь весьма надежен, но для получения гарантированного результата он требует много времени, усилий и практического опыта. В наше время главным критерием является эффективность и скорость взлома, а не «правильность», которая интересна лишь «гуру» и вечно недовольным теоретикам. К тому же, если Вы только начали обучаться крэкингу, у Вас может просто не оказаться нужных знаний и опыта, чтобы знать, в каком направлении нужно двигаться. Итак – налицо парадокс: чтобы приобрести опыт, нужно ломать программы, причем ломать успешно, и чем больше – тем лучше, а ломать их не получается из-за недостатка опыта. Но существует второй путь – исследовать программы, исходя из предположений, которые, в свою очередь, строятся на наблюдении за внешними эффектами, производимыми программой.То есть Вы не сразу начинаете выяснять, что и как происходит в недрах кода программы, а сначала строите предположения, «на что это может быть похоже», «как это может быть реализовано» и «как бы я добился такого эффекта, будь я на месте автора программы». Как ни странно, при использовании этого метода, успех зависит не только от знаний, но и от того, насколько богато Ваше воображение. Эффективность дзен - крэкинга опирается, прежде всего, на наблюдения и смелые предположения. Поэтому не надейтесь, что авторы защит будут применять избитые приемы, которые можно аккуратно переписать на бумажку и составить «инструкцию по взлому». Ожидайте неожиданного! Однако Вы не можете строить предположения о работе программы на пустом месте – Вам нужен некий стартовый набор знаний. Поэтому Вы должны собрать как можно больше информации о самой программе – выяснить, упакована она или нет, какие ограничения содержатся в незарегистрированной программе и как выглядит процесс регистрации; узнать, что будет, если Вы попытаетесь использовать программу дольше, чем это предусмотрено триальными ограничениями; проанализировать, какие текстовые строки и ресурсы содержатся внутри программы; поинтересоваться, к каким файлам и ключам реестра программа обращается при запуске, и многое другое. Не поленитесь заглянуть в справочную систему программы – там Вы можете найти описания различий между зарегистрированной и незарегистрированной версией. Значительная часть этой информации Вам скорее всего не пригодится, но Вы не можете знать заранее, какой путь окажется наиболее удобным и какие знания о программе Вам понадобятся. Более того, почти наверняка все необходимые данные о работе программы Вы с первой попытки не соберете, и уже в процессе изучения кода Вам придется возвращаться к этому этапу, чтобы узнать, например, сколько с какими параметрами программа в заданной точке вызывает функцию создания файла, куда считываются введенные серийные номера, сколько раз и откуда программа вызывает функцию проверки регистрации и т.п.
А потому – собирайте информацию! Когда Вы сгенерировали идеи о том, как именно могут работать интересующие Вас механизмы в программе, перед Вами встанет задача добраться до кода, который эти механизмы реализует. Для этого Вам нужно проанализировать все предполагаемые варианты и найти, к чему можно «прицепиться» в каждом случае. Иными словами, Вы должны представить, чем может отличаться интересующий Вас кусок кода от множества других кусков, и чем более явными будут эти отличия, тем легче Вам будет этот код найти. Например, если программа выводитnag-screen, можно «прицепиться» к функциям создания и отображения окон; если предполагается проверка CRCфайла, результатом будет либо многократное чтение небольших блоков, либо загрузка или отображение всего файла в память; если в заголовке окна программы большими буквами написано UNREGISTERED, можно поискать эту строчку в программе и выяснить, откуда и при каких условиях происходит обращение к этой надписи. И, наконец, Вам придется разработать практические приемы, при помощи которых можно добраться до интересующих Вас кусков кода, выбрать подходящий инструмент из своего арсенала и правильно его применить. Как это работает на практике? Представьте себе программу, которая делает нечто. Например, отказывается запускаться после 30 дней использования, выдавая стандартное окошко с сообщением (широко известное как MessageBox). И так, у нас есть первое наблюдение. Простой перевод системного времени не помогает – обмануть программу и заставить ее работать дольше, чем положено, не удается. Это второе наблюдение. Из него следует, что программа проверяет текущую дату не на основе показаний внутренних часовWindows. Предполагаем, что программа либо уже сделала пометку «больше не запускаться» где-нибудь в реестре или на диске, либо все-таки определяет текущее время, но каким-либо хитрым способом. Например, читая дату последнего доступа или модификации какого-либо файла. У нас уже есть целых два смелых предположения, которые можно брать за основу в дальнейшем расследовании вредительской деятельности защиты.
Если программа не просто «задумывается» при запуске, но еще и шуршит винчестером, вероятность второго варианта сильно повышается. Теперь начинаем проверять эти варианты. В первом случае нам однозначно проще докопаться до истины, установив точки останова на все виды MessageBox’ов и выяснять, какой из условных переходов позволяет избежать появления этого сообщения. Во втором случае в качестве отправной точки можно использовать всевозможные GetFileTime, CompareFileTime (а чем не способ – сравнить дату создания файла программы, т.е. дату инсталляции с датой последней модификации какого-либо файла) и FindFirstFile/FindNextFile (они ведь тоже способны читать временные характеристики файлов). Абсолютное большинство защит, как бы аккуратно они не были реализованы, все-таки имеют «ахиллесову пяту». Эта уязвимость может быть запрятана глубоко в недрах кода, размазана по нескольким десяткам процедур или же быть совершенно неочевидной – но она есть. Стоит только ее обнаружить и нанести точный удар – и защита развалится, как карточный домик. Следовательно, залогом успешного взлома является отыскание уязвимых мест в защите. Теперь, когда мы знаем, что нам нужно искать, осталось только определить, как выглядят эти уязвимости. Наиболее «удобные» для крэкера дыры – это прежде всего глобальные переменные, в которых хранится состояние программы («зарегистрирована - не зарегистрирована»), функции, возвращающее критичное для защиты значение (число запусков или дней до истечение триала, результат проверки серийного номера на правильность) и процедуры, выводящие сообщение об успешной или неуспешной попытке регистрации, а также об истечении триального срока программы. Одним из величайших «шедевров», встреченным мной, была глобальная переменная в секции инициализированных данных. По умолчанию ее значение было равно нулю, и менялось на единицу если серийный номер, извлекаемый из реестра, был верен. Исправление одного-единственного бита превратило программу в зарегистрированную. Другим перспективным приемом, который, правда, эффективен в основном против ограничений максимального/минимального значения какого-либо числового параметра (количества обрабатываемых документов, числа запусков и т.п.) является поиск константы, с которой производится сравнение, и модификация либо самой константы, либо условия проверки.
Более подробно о том, как обращаться с переменными и константами, я расскажу в соответствующей главе. «Регистрация» программ, где защитная функция возвращает единственное несколько сложнее – в этом случае требуется выявить все точки, в которых функция возвращает какое-либо значение, и подправить это значение. Нужно только помнить, что куски кода вроде xor eax,eax … ret могут встречаться в теле функции в нескольких экземплярах, и обезвредить нужно их все. Да и возвращаемое значение совершенно не обязательно является ноликом или единичкой. Другая проблема для программистов защит, которая сильно облегчает жизнь крэкерам (вот уж воистину «что русскому хорошо, то немцу - смерть») это «проблема условного перехода». Эта проблема заключается в том, реализовать проверку какого-либо условия, не использовав команду условного перехода, не так уж просто. Чем же так выделяются команды условных переходов? А тем, что любой такой переход очень просто превратить в такой же, но с противоположным условием – обычно для этого достаточно исправить ровно один бит! Несмотря на техническую простоту, правка условных переходов все-таки менее предпочтительна, чем модификация функций. Причина этого в том, что условных переходов, имеющих отношение к защите, в программе может быть довольно много (обычно – заметно больше, чем кусков кода, отвечающих за возврат результата функции), и их поиск требует особой внимательности. К тому же, если защита вместо обычных функций использует inline-функции или макросы, разбросанные по всей программе, защитный механизм выглядит как длинный и внешне однородный кусок кода, поиск «плохих» переходов внутри которого несколько затруднителен. С другой стороны, если в таких защитных вставках используются вызовы каких-либо «нормальных» (не inline) функций, особенно функций WinAPI, найти такие идентичные куски становится не так уж сложно. В таком блоке кода почти наверняка есть комбинации команд, по которым такой кусок кода можно идентифицировать – так что поможет либо поиск в двоичном файле программы с использованием маски, либо в дизассемблированном тексте - с использованием регулярных выражений.
Если запастись терпением, можно даже проверить все подозрительные участки программы вручную, это вполне реально, если таких участков в программе не больше двух десятков. Теперь Вам известны наиболее часто встречающиеся в защитах дыры, в выявлении которых заключена половина успеха крэкера. Пришло время рассмотреть трудности и «подводные камни», которые могут ожидать Вас в нелегком кэкерском труде. Прежде всего это проблема неверной интерпретации собранной информации. Например, Вы обнаружили, что программа поддерживает использование плагинов и при запуске сканирует все файлы с расширением DLL в собственной директории. Вы вполне логично предполагаете, что программа строит список плагинов для дальнейшей загрузки и подключения. А потом Вы можете очень долго искать механизм определения даты первого запуска – и не найти его. Потому что он уже отработал – как раз при поиске плагинов. Как такое может быть? Да очень просто: в комплект программы включается как минимум один плагин. Далее – обычная привязка к дате последней модификации файла этого плагина; саму дату модификации файла несложно получить в ходе поиска через FindFirst/FindNext. Вот так иногда авторы защит прячут свой вредительский код, что называется, «на самом видном месте». Другой пример неверной интерпретации собранных данных встречается еще чаще; более того, это неизбежное следствие подхода, принятого в дзен - крэкинге. Если Вы нашли условный переход, который начисто «выключает» все сообщения о незарегистрированности программы, из этого не обязательно следует, что программа будет вести себя в точности как зарегистрированная. Убедиться в стопроцентной надежности (или принципиальной неправильности) исправления этого перехода – подчас задача много более сложная, чем найти этот самый условный переход. Что интересно, это утверждение работает и в обратную сторону: если Вы что-то сделали, но не увидели результат, это совершенно не означает того, что Вы ошиблись. Создатели защит не так уж редко создают многоуровневую оборону, и для одержания победы недостаточно разрушить внешние рубежи защитного кода.Если вы что-то сделали, но не получили желаемого результата, не стоит сразу же бросать избранный путь; возможно, что Вы абсолютно правы и необходимо двигаться тем же путем и дальше. Даже если после Ваших манипуляций подопытная программа рушится с «ужасным» GPF, этот GPF может быть всего лишь еще одной, еще не выявленной уловкой создателя защиты. [C] CyberManiac Содержание Далее
Орудия крэкера.
Ваше слово, товарищ «Маузер» В. Маяковский Осмелюсь предположить, что Вы читаете этот текст не из праздного интереса или ради абстрактного «знания», а хотите научиться применять эти знания на практике. То есть ломать программы. И хотя это пособие носит название «Теоретические основы…», Вы не замедлите применить эти «основы» на практике. А поскольку крэкинг отнюдь не ограничивается теорией, Вам потребуются «рычаги», при помощи которых Вы сможете перевернуть код. И именно об этих «рычагах» и пойдет речь в данной главе. Раз обучение крэкингу требует постоянной и разнообразной практики, было бы логично начать с перечисления того, что Вам потребуется для «практических занятий». Но, с другой стороны, вряд ли Вы сможете выбрать наилучшие инструменты, не зная хотя бы в общих чертах особенностей Вашей будущей деятельности. И именно поэтому глава носит номер «минус один», но следует за «нулевой» главой, в которой я попытался объяснить, чем Вы будете заниматься и какие трудности могут Вас ожидать. Инструменты – это материальная основа крэкинга, без инструментов в крэкинге – никуда, но сами по себе инструменты – не более чем набор байтов, и лишь человеческая мысль оживляет их, заставляя творить чудеса. Для использования этих программ, как правило, требуются соответствующие знания. Насколько будет Вам полезен лучший отладчик в мире, если Вы не знаете, что означают те хитрые цифры и буквы, которые он показывает на экране? Поэтому прежде чем задать программе вопрос, Вы должны быть уверены, что сможете понять ответ. Но, тем не менее, от качества этих инструментов будет во многом зависеть скорость и эффективность Ваших действий. Ваш инструментарий должен постоянно обновляться; не надейтесь, что софт десятилетней давности сможет чем-то Вам помочь – в области высоких технологий радикальные изменения могут произойти за считанные дни. Программы совершенствуются, наращивают мощность, обрастают новыми полезными и бесполезными функциями; защиты также не стоят на месте – и Вам нужно быть в курсе этих процессов, своевременно обновляя свой арсенал. К вашему счастью, инструментов, пригодных для использования в крэкинге, не так уж мало, и проблема состоит не в том, чтобы их раздобыть, а в том, чтобы отобрать из них наилучшие, изучить их возможности и определить для себя, в какой ситуации тот или иной инструмент лучше всего применить.Поясню эту мысль на примере: на данный момент наиболее мощным из дизассемблеров является IDAPro, которая способен не просто дизассемблировать код, но и находить в нем вызовы стандартных функций тех или иных компиляторов. Однако если мне необходимо покопаться в программе, написанной наDelphi версии выше третьей, я наверняка не буду использоват ьIDAPro, предпочтя ему DelphiDecompiler. Почему? Во-первых, скорость работы IDAPro и DeDe различается в десятки раз в пользу последнего; используя DeDe, я скорее всего получу желаемый результат раньше, чем закончилось бы дизассемблирование в IDAPro. Во-вторых, DeDe позволяет анализировать работающие процессы «на лету», что позволяет анализировать сжатые программы, не отвлекаясь на распаковку, восстановление таблицы импорта и прочие вспомогательные действия. Не углубляясь в дальнейшее перечисление достоинств DeDe, начисто отсутствующих в IDA, скажу, что в большинстве случаев специализированная программа позволяет решать свой «родной» класс задач значительно эффективнее, чем программы «общего назначения», ориентированные на ручную работу. Конечно, никто не запретит вам распаковывать программы, вручную копируя секции из памяти в файл, но не проще ли воспользоваться дампером? Наверняка найдутся те, кто возразит, что широкое использование готовых утилит якобы мешает самостоятельному мышлению, чрезмерно упрощает процесс взлома и вообще «настоящие хакеры дизассемблируют в уме». Так вот, мое принципиальное мнение по этому поводу такое: используйте все программы, какие только сочтете нужными, если это поможет вам добиться желаемого. В конце-концов, цель крэкера обычно состоит в получении работающей программы, а никак не в тренировке памяти или демонстрации собственной крутизны. А всем «настоящим хакерам» посоветуйте дизассемблировать в уме winword.exe из состава самого последнего MSOffice, и до тех пор, пока они это не сделают, не беспокоить вас древними суевериями. Разумеется, рано или поздно Вы перейдете от использования чужих программ к написанию собственных патчеров, распаковщиков, дамперов и прочих утилит – но такой переход должен быть продиктован насущной необходимостью, а не обезьяним «чтобы быть не хуже других».
В конце - концов, большинство программ было написано именно для того, чтобы люди ими пользовались. Итак, какие инструменты и для чего нам потребуются?
Интерпретатор может как поставляться отдельно (Java), так и быть «прикрепленным» к самой программе (хотя формально не интерпретатор прикрепляется к программе, а программа к интерпретатору. Примером может служить VisualBasic при компиляции в p-code). Возможны и более экзотические варианты, например, применяемый в Форте «шитый код»; компиляция программы в цепочку команд push\call или преобразование текста программы в такую цепочку непосредственно при запуске этой программы. Интерпретаторами являются практически все инсталляторы (в их основе лежит интерпретатор инсталляционного скрипта, хотя сам процесс создания такого скрипта может быть скрыт при помощи использования визуальных средств). Да и «обычные» компилирующие языки могут создавать код, прямой анализ которого весьма затруднителен. Для анализа таких программ используются специализированные утилиты, переводящие код, понятный лишь интерпретатору, в форму, более удобную для понимания человеком. Также некоторые декомпиляторы могут извлекать информацию об элементах интерфейса, созданных визуальными средствами. В любом случае, не следует ожидать от декомпиляторов восстановления исходного текста программы; если декомпилированная программа успешно компилируется и сохраняет работоспособность – это исключение, а не правило.
Защитные системы нередко пытаются противодействовать получению работоспособного дампа при помощи манипуляций с сегментами и таблицами импорта-экспорта. В этих случаях приходитсяPE - реконструкторы, т.е. утилиты, обнаруживающие в дампе некорректные ссылки на функции и пытающиеся их восстановить. Многие продвинутые дамперы и распаковщики имеют встроенные средства восстановления секций импорта.
Очень часто бывает нужно узнать, какие именно действия выполняет та или иная программа, откуда читает и куда записывает данные, какие стандартные функции и с какими параметрами она вызывает. Получить эти знания как раз и помогают утилиты мониторинга. Такие утилиты делятся на две большие группы: те, которые отслеживают сам факт возникновения каких-либо событий и те, которые позволяют выявить один или несколько специфических типов изменений, произошедших в системе за некий промежуток времени. К первой группе относятся всевозможные API-шпионы, перехватывающие вызовы системных (а более продвинутые - и не только системных) функций, хорошо известные утилиты Reg-,File-,PortMon и т.д., перехватчики системных сообщений и многие другие. Эти утилиты, как правило, предоставляют весьма подробную информацию об отслеживаемых событиях, но генерируют весьма объемные и неудобные для анализа логи, если отслеживаемые события происходят достаточно часто. Вторая группа представлена всевозможными программами, создающими снимки реестра, жесткого диска, системных файлов и т.п. Эти программы позволяют сравнить состояние компьютера «до» и «после» некоего события, построить список различий между состояниями и на основе этого списка сделать определенные выводы. Основная проблема при работе с такими утилитами хорошо описывается словами “«после» – не значит «вследствие»” (т.е. кроме интересующей Вас деятельности программы Вы можете получить лог «жизнедеятельности» других параллельно работающих программ и операционной системы).
Слишком хорошо – тоже не хорошо.
Как Вы уже успели убедиться, обращение с брейкпойнтами – занятие далеко не такое простое, как может показаться с первого взгляда. В предыдущей главе мы разбирались в том, почему точки останова иногда не делают того, что мы от них ожидаем, значительная же часть этой главы посвящена полностью противоположной проблеме: что делать, если точки останова срабатывают слишком часто. После всего написанного в предыдущей главе такая ситуация может показаться Вам невероятной, но, представьте себе, такое тоже бывает. Если Вы входите в число поклонников отладчика SoftIce, то Вы не могли не заметить одну милую особенность этого инструмента: если Вы поставили брейкпойнт непосредственно на системную функцию, SoftIce будет «всплывать» при каждой попытке исполнить эту функцию независимо от того, внутри какого процесса функция была вызвана. Такое поведение отладчика, несомненно, бывает полезным при отладке драйверов, хук-процедур и самой операционной системы. Но наша беда (или счастье – это уж с какой стороны посмотреть) в том, что мы отлаживаем «обычную» программу, и постоянные выпадения в отладчик по совершенно неинтересным поводам – это далеко не то, о чем мы мечтали. Вот если бы удалось сделать так, чтобы брейкпойнты работали внутри только одного процесса… Те, кто начал знакомиться с SoftIceпо пакету NuMegaDriverStudio 2.6, скорее всего не увидят в этом никакой проблемы: BPX <имя_функции> IFPID=Как я отмечал, поставить брейкпойнт на функцию WinAPI, срабатывающий исключительно внутри нужного процесса (по-научному брейкпойнты с такими свойствами называются «address-contextsensitive», они же «контекстно-зависимые»), напрямую было невозможно. Чтобы почувствовать, насколько серьезной была проблема, представьте себе следующую картину: бряк, поставленный на функции чтения из реестра (которая называется RegQueryValue[Ex], надеюсь, Вы еще не забыли мой «поминальник»), работает настолько хорошо, что отлавливает абсолютно все попытки чтения из реестра, независимо от того, выполняет их отлаживаемая Вами программа или какая-либо другая. Любые попытки осмысленной отладки в такой ситуации заведомо обречены на провал, единственное, чем Вы будете заниматься – это нажимание клавиш Ctrl-D, ибо Windowsнастолько сильно любит читать данные из реестра, что делает это много раз в секунду (если хотите своими глазами посмотреть на эту странную любовь, RegMon Вам в этом поможет). Так что же, поставить бряк на RegQueryValueExи получить от этого удовлетворительный результат совсем никак невозможно? Как бы не так! «Если нельзя, но очень хочется, то все-таки можно». Начнем с небольшого, но очень важного определения: адресный контекст (он же контекст процесса) – это все виртуальное адресное пространство, выделенное данному процессу. «Военная хитрость», при помощи которой мы «проапгрейдим» контекстно-независимый брейкпойнт до контекстно-зависимого, основана на том факте, что хотя SoftIce и игнорирует контекст в момент срабатывания контекстно-независимых брейкпойнтов (в число которых входят и брейкпойнты на функции WinAPI), сам текущий адресный контекст от этого никуда не исчезает. И потому в момент срабатывания условного брейкпойнта отладчик вполне способен прочитать любые данные из существующего в этот момент виртуального адресного пространства. Проще говоря, если брейкпойнт сработал внутри программы X, то отладчик сможет «увидеть» адресное пространство программы X вместе со всеми данными, содержащимися в этом пространстве, а заодно и содержимое всех регистров, каким оно было в момент срабатывания брейкпойнта.
А если отладчик «видит» все адресное пространство процесса и способен читать исполняемый код программы, значит, можно попытаться идентифицировать процессы по особенностям их исполняемого кода! На практике эта «идентификация по особенностям исполняемого кода» выглядит весьма прозаично: нужно забраться при помощи шестнадцатиричного редактора внутрь секции кода (впрочем, для идентификации можно использовать и любые другие заведомо не изменяющиеся при работе программы данные), выдернуть оттуда первый попавшийся DWORD (назовем его My_DWORD) и запомнить виртуальный адрес (My_Addrсоответственно), по которому этот DWORD находился. Дальнейшие операции, выполняемые уже в отладчике, ненамного сложнее: BPXимя_функции_WinAPIIF(*My_Addr)==My_DWORD. Все. Думаю, с пониманием того, что делает эта команда, ни у кого сложностей не возникло: мы соорудили условную точку останова, которая в момент срабатывания проверяет, не лежит ли по адресу My_Addrдвойное слово, равное My_DWORD. И если такое двойное слово по нужному адресу обнаруживается, то отладчик приостанавливает исполнение программы. Поскольку случайно встретить пару программ, у которых в секции кода по одним и тем же виртуальным адресам находились бы одинаковые DWORD’ы, вряд ли возможно, такой простейший способ различения процессов в абсолютном большинстве случаев отлично срабатывает. Сложности могут возникнуть только в двух случаях: при отладке упакованных программ и если нужно параллельно отлаживать два экземпляра («экземпляр» следует понимать как «instance») одного и того же приложения – поскольку код обеих процессов идентичен, в общем случае различить их по содержимому адресного пространства затруднительно. В настоящее время применение описанного выше метода для определения, в контексте какого из процессов сработал брейкпойнт, в общем-то, не требуется – в современных версиях SoftIce(вообще, сам отладчик SoftIce, особенности работы с ним и различия между разными его версиями – это отдельная большая тема, которая будет закрыта не раньше, чем прекратится развитие самого SoftIce) эта проблема решается безо всяких ухищрений.
Однако поскольку особенности отдельных инструментов имеют весьма слабое отношение к теоретическим вопросам крэкинга, которым посвящена данная работа, я в дальнейшем не буду акцентировать внимание на этих особенностях – разобравшись в предлагаемом материале, Вы сами найдете способ с максимальной эффективностью использовать эти особенности. Для того же, чтобы избежать путаницы при изложении материала этой главы, мы будем считать, что все брейкпойнты, о которых ниже пойдет речь, являются контекстно-зависимыми, то есть работают исключительно в рамках того процесса, в котором они установлены. Таким образом, мы «уравняем в правах» SoftIce и отладчики третьего кольца защиты («ring3 debuggers»), к которым, в частности, относится OllyDebug. Само по себе создание условного брейкпойнта, срабатывающего исключительно при появлении по некоему адресу известного значения – прием весьма часто употребляемый и полезный во множестве ситуаций. Самое известное из применений этой техники: «остановить программу, если переменная равна некоему значению» мы рассматривать не будем по причине крайней его банальности, такие вещи Вы способны проделывать самостоятельно, и, возможно, даже с закрытыми глазами. Куда менее очевидна возможность использования брейкпойнтов чтобы «притормозить» упакованную программу сразу после начала ее исполнения. Возможно, Вы уже встречали в статьях по крэкингу сокращение «OEP», которое расшифровывается как OriginalEntryPoint, «оригинальная («истинная») точка входа». Возможно, Вы также читали о том, что распаковка сжатых исполняемых файлов включает в себя поиск адреса этой самой OEP. Если же Вы ничего такого еще не читали и не встречали, то Вам необходимо запомнить следующие базовые сведения:
Originalentrypoint – это entrypointисполняемого файла до того, как файл был сжат/зашифрован и значение точки входа было модифицировано упаковщиком.
Извлечь нужное значение дампером из запущенной программы обычно не составляет никакой сложности. Поскольку известные мне упаковщики распаковывают код в направлении от меньших адресов к большим (и с достаточной точностью можно предположить, что так работает большинство распаковщиков), лучше выбирать эталонное значение ближе к концу программы. После того как такое значение у Вас будет, нужно лишь поставить условную точку останова на нужную функцию, а в качестве дополнительного условия указать равенство значения по адресу Aэталонному значению, которое Вы извлекли из распакованной программы. В результате Ваш брейкпойнт сработает не раньше, чем будет распакована соответствующая часть программы, так что довольно велика вероятность того, что, продолжая отладку в пошаговом режиме, Вы вернетесь в код программы, а не в недра распаковщика. Наблюдая за работой запакованных программ, Вы могли заметить, что они после распаковки всегда располагаются в памяти по одним и тем же адресам. Применив против такой программы какую-нибудь утилиту вроде ProcDump, Вы даже можете прочитать параметры секций программы, частности - начальный адрес и размер. После этого вполне естественным кажется вопрос: «а что, если проверять, принадлежит ли адрес возврата, лежащий на стеке, промежутку адресов, в котором расположен код программы?» И вопрос этот отнюдь не праздный. Дело в том, что нередки упаковщики и навесные защиты, в которых блок, выполняющий дешифровку и распаковку кода, располагается в области, не пересекающейся с той, в которой в итоге будет расположен исполняемый код программы. Поэтому, поставив брейкпойнт на функцию и в качестве условия указав что-нибудь вроде ([esp]>401000) && ([esp]<501000) Вы добьетесь того, чтобы Ваш брейкпойнт активизировался лишь в том случае, если при выходе из функции предполагается возврат в код отлаживаемой программы. Данный метод, также как и описанный выше, может использоваться для поиска OEP через обратную трассировку от первого вызова функции WinAPI.
Однако этим возможности условных точек останова, проверяющих адрес возврата, отнюдь не ограничиваются. Очень часто возникает необходимость посмотреть параметры вызова некой часто используемой функции в тех случаях, когда вызов этой функции был сделан из одной или нескольких точек, и при этом проигнорировать все прочие вызовы (которых может быть очень много). Если решать задачу «в лоб», то необходимо найти все ссылки на данную функцию и установить по брейкпойнту на каждый интересующий нас вызов. Обычно такой подход вполне удовлетворителен, но мы не будем искать легких путей и посмотрим, как ту же задачу можно решить при помощи одного единственного брейкпойнта. Вы уже знаете, что для распознавания, откуда был сделан вызов функции, можно использовать адрес возврата, лежащий на вершине стека, и потому без труда напишете соответствующее условие для брейкпойнта. Такое условие может выглядеть следующим образом: ([esp]==ret_addr1) || ([esp]==ret_addr2) || …, где ret_addr1 и ret_addr2 – адреса возврата. Все это достаточно очевидно, и Вы можете задать вопрос, зачем нужно было изобретать очередной велосипед, если традиционный подход дает ничуть не худшие результаты? Первая причина - «человеческий фактор»: работать с большим количеством точек останова не всегда удобно даже в насквозь визуальном OllyDebug, а уж «рулить» десятком-другим брейкпойнтов в SoftIce– занятие, что называется, на любителя. Так что если есть возможность значительно уменьшить число брейкпойнтов и облегчить себе жизнь, почему бы этой возможностью не воспользоваться? Кроме того, перед Вами может встать задача, обратная по отношению к вышеприведенной: отслеживать все вызовы процедуры, за исключением нескольких. И в этом случае сформировать строку с условием вида ([esp]!=ret_addr1) && ([esp]!=ret_addr2) && …, в которой ret_addr1, ret_addr2 – адреса возврата, при которых точка останова не должна срабатывать, представляется гораздо более простым, чем искать в коде программы все подозрительные ссылки на интересующую Вас процедуру и «обвешивать» их брейкпойнтами. Однако более важным представляется применение условных брейкпойнтов для обнаружения неочевидных вызовов функций.
Я уже рассказывал в предыдущей главе о приемах, при помощи которых разработчик защиты может вызывать процедуры неявным образом, из-за чего такие обращения к процедурам становятся «невидимы» для дизассемблеров и прочих инструментов анализа «мертвого кода». В качестве дополнительного средства маскировки авторами защит могут параллельно использоваться как обычные вызовы процедур, так и неочевидные; я встречал такое в приложении к MessageBoxA и к функции чтения из реестра, но вообще эта техника может быть применена к любой достаточно часто вызываемой процедуре, используемой в защитных механизмах. В результате в дизассемблированном коде мы увидим несколько ничем не примечательных явных вызовов – но, скорее всего, не заметим самого интересного. Поставив «обычный» брейкпойнт на вызываемую функцию, мы можем столкнуться с тем, что эта функция вызывается десятки раз, и потому тоже не сможем определить, задействована ли эта функция в защитном механизме – интересующие нас неявные вызовы потеряются среди десятков и сотен вызовов явных. Вот если бы был какой-нибудь способ отделить явные вызовы от неявных… И такой способ есть. Базовые идеи, используемые для различения явных и неявных вызовов, могут быть сформулированы следующим образом: 1. Практически все явные вызовы функций легко обнаруживаются дизассемблером или вспомогательными инструментами для поиска ссылок (к примеру, в OllyDebug эта операция элементарно выполняется из контекстного меню: Findreferencesto|selectedcommand). Построив список явных вызовов, можно записать условие-фильтр, в котором будут перечислены все адреса возврата после явных вызовов, и использовать этот фильтр в качестве условия срабатывания для точки останова. Проще говоря, нам нужно будет установить условный брейкпойнт с фильтром вида ([esp]!=ret_addr1) && ([esp]!=ret_addr2) && …, который бы «пропускал» явные вызовы, но срабатывал на всех остальных, то есть неявных. 2. Набор «штатных» команд ассемблера, предназначенных для вызова подпрограмм, сравнительно невелик, поэтому, проанализировав несколько байт, предшествующих адресу возврата, можно строить предположения о способе вызова.
В частности, если опкод, находящийся на пять байт «выше» адреса возврата, равен 0E8h (CALLxxxx), скорее всего вызов был сделан стандартным способом. Если же в окрестностях адреса возврата ни одна из разновидностей команды CALL не обнаружена – либо вызов был неявным, либо внутри отработавшей процедуры присутствовал код, искажающий адрес возврата. 3. Код, предшествующий вызову подпрограммы, может оставлять легко идентифицируемые следы: определенные состояния флагов, значения переменных или соотношения между регистрами. В некоторых случаях, проанализировав эти следы, можно делать выводы, из какой точки могла (или не могла) быть вызвана та или иная подпрограмма. Поясню эту мысль на следующем коде: cmp eax, ebx ja _no_call call MyProc _no_call: … Нетрудно заметить, что если вызов процедуры MyProc был сделан из вышеприведенного куска кода, то в момент входа в процедуру значение регистра eaxдолжно быть меньше либо равно ebx (в противном случае выполнилась бы команда ja, обходящая call). И если это соотношение между регистрами в каких-то случаях окажется недействительным, для крэкера будет совершенно очевидно, что в этих «подозрительных» случаях вызов подпрограммы был произведен откуда угодно, но только не из продемонстрированного кода. Разумеется, при большом желании, возможно построить код программы таким образом, что приложение вышеприведенные идей не даст положительного результата. Однако это потребует от программиста дополнительных и довольно серьезных усилий – разработчику придется проанализировать собственный код так, как это сделал бы крэкер (и отнюдь не факт, что это у него получится – принципиально разные цели порождают разный образ мышления). Затем все найденные уязвимые участки придется переписать, причем наверняка - на ассемблере, поскольку высокоуровневые языки обычно не дают необходимой гибкости. После всего этого все переписанные связки между процедурами придется заново отладить протестировать, чтобы убедиться в полной корректности работы исправленного кода.
В общем, объем работ нешуточный, а результат… Результат, как водится, заведомо неизвестен – никто не может гарантировать, что разработчик не упустит какой-либо важный момент, или крэкер не найдет особо нетрадиционный подход, после которого все усилия автора защиты пойдут прахом. Так что обычно разработчики обычно не озабочиваются сокрытием «тонких эффектов» при неявных вызовах, и Вы, при случае, можете обратить это себе на пользу. Увы, в настоящее время применить вторую и третью идею в полном объеме, мы обнаружим ограниченность имеющихся программных средств. Да, наши любимые отладчики отлично справляются с относительно простыми фильтрами, однако более глубокий анализ ситуации в момент срабатывания брейкпойнта, невозможен либо, по меньшей мере, крайне затруднителен. Действительно, попробуйте написать условие, которое проверяло бы наличие всех возможных разновидностей команды CALL в районе адреса возврата – и Вы поймете, что я имею в виду. А ведь иногда возникает необходимость не только (и не столько) остановить программу при тех или иных значениях регистров, но в автоматическом режиме собрать статистику по срабатыванию брейкпойнта – частота появления тех или иных адресов возврата, типичные значения параметров функции, на которую поставлен брейкпойнт и тому подобное. И тут становится очевидным, что даже «статистические» команды SoftIce и модификатор DO в командах установки брейкпойнтов являются лишь слабым подобием того, в чем рано или поздно возникает потребность у каждого крэкера. Идеальным решением была бы встраивание в отладчики собственного скриптового языка, обеспечивающего полный доступ ко всем возможностям отладчика (что уже частично реализовано в плагинах для OllyDebug и различных «сторонних утилитах» для SoftIce). Если же существующие реализации средств скриптинга не предоставляют необходимых возможностей, мы будем вынуждены обходиться программными «затычками», реализация которых аналогична устройству описанных в предыдущей главе точек останова в Spectrum’овских отладчиках.
Поскольку применяются такие «брейкпойнты» (а по сути - патчи) довольно широко, а с необходимостью «перехватить» исполнение программы в нужной точке рано или поздно сталкивается любой крэкер, мы подробно рассмотрим эту технологию в главе, посвященной патчингу. До настоящего момента мы как-то обходили вниманием точки останова, срабатывающие при попытке доступа к определенным областям памяти вообще, и аппаратные брейкпойнты в частности. Вы, возможно, даже начали беспокоиться из-за того, что, говоря о точках останова, я так долго не упоминал волшебное слово «BPM». И вот пришло время поближе узнать, что такие брейкпойнты собой представляют и какую практическую пользу из них можно извлечь. «Законный» способ установки брейкпойнтов на области памяти основывается на использовании специальных отладочных регистров, обозначаемых как DR0-DR7. Каждый из брейкпойнтов может отслеживать любой (но только один) из следующих типов обращения к памяти: запись, чтение, запись или чтение, исполнение кода. Операции «чтение» и «исполнение» процессор считает принципиально разными, несмотря на то, что здравый смысл говорит нам: прежде чем исполнить код, нужно его прочитать. «Это невозможно понять, это нужно запомнить» - поэтому временно отложите здравый смысл в сторонку и запомните это правило. По этой же причине одновременно отслеживать запись, чтение и исполнение при помощи одного-единственного брейкпойнта у Вас не получится. Это первое существенное ограничение, наложенное инженерами из Intel на использование отладочных регистров. Впервые отладочные регистры появились в процессорах 80386 именно для отслеживания обращений к памяти, но в Pentiumвозможности этих регистров были распространены и на порты ввода-вывода. Поскольку собственно адреса точек останова (или номера портов, обращение к которым будет отслеживаться) задаются в регистрах DR0-DR3, таких брейкпойнтов может быть не более четырех – это второе ограничение. Еще одна проблема состоит в том, что отладочные регистры позволяют установить брейкпойнт только на байты, слова (WORD) или двойные слова (DWORD), аппаратных брейкпойнтов на обращения к более крупным блокам памяти не предусмотрено.
Если Вам нужно отследить обращения к переменным «длинных» нецелочисленных типов (Double, Extended), Вы моежете поставить брейкпойнт в середину переменной; в этом случае любое обращение к такой переменной «зацепит» брейкпойнт. Также важно помнить следующее: если Вы устанавливаете бряк на слово, адрес брейкпойнта будет автоматически выровнен на ближайший «снизу» четный адрес, а если Вам нужен бряк на DWORD – приготовьтесь к тому, что процессор выровняет адрес брейкпойнта на адрес, кратный четырем. И, наконец, самый неприятный для крэкера факт – работа с отладочными регистрами возможна только из нулевого кольца защиты, так что любителям запустить свои шаловливые ручки в недра системы и сотворить там что-нибудь эдакое придется сначала повозиться с написанием соответствующего софта. Впрочем, пусть Вас согревает тот факт, что разработчикам защит добраться до отладочных регистров и испортить Вам удовольствие будет ничуть не проще. Если у Вас возник живой интерес к теме внутреннего устройства аппаратных точек останова, могу порекомендовать обратиться к первоисточникам, то бишь к фирменной документации Intel. Пожалуй, пора заканчивать с живописаниями грядущих трудностей и прочими ужасами, а то, добравшись до последней главы этой работы, Вы можете решить, что крэкинг – это очень сложно, больно и трудно (хотя в действительности это не совсем так). Поэтому давайте лучше устроим сеанс позитивного мышления и погрузимся в созерцание всего того доброго, светлого и прекрасного, которое привносят в нашу жизнь аппаратные брейкпойнты. Ибо, воистину, тяжела была бы наша жизнь, не будь в ней аппаратных точек останова. Нетрудно догадаться, что изначально аппаратные брейкпойнты предполагалось применять для поиска ошибок, связанных с некорректной работой с переменными, и отлавливать ситуации, чреватые переполнением буфера (для этого сразу после последнего байта буфера ставился «защитный» брейкпойнт, срабатывание которого сигнализировало о попытке записать или прочитать лишние данные). Однако крэкеры даже столь милым и безобидным штукам, как аппаратные точки останова, нашли нетрадиционное применение, превратив их в главную «ударную силу» против самомодифицирующегося и упакованного кода. Если Вы пробовали поставить брейкпойнт на упакованный код, Вы не могли не заметить, что после этого действа программа отчего-то перестает корректно распаковываться (впрочем, с вероятностью приблизительно 1/256, программа распакуется даже после такого издевательства, но брейкпойнт, разумеется, работать не будет).
Прочитав предыдущую главу, Вы наверняка осознали всю глубину и тяжесть Вашей ошибки, чистосердечно раскаялись в этом ужасном деянии и, положив руку на руководство по отладчику, трижды произнесли торжественное обещание никогда больше так не поступать. А потом, подобно классику, задались вопросом: «что делать?» Разыскивая ответ на этот глубоко философский вопрос, Вы могли обнаружить в руководстве по SoftIce раздел, повествующий о команде BPM и ее параметрах, либо добраться до таинственных пунктов Breakpoint|Hardware, on… в контекстном меню OllyDebug. Если Вы еще не проделали этих операций – прочитайте документацию по отладчику, посмотрите, как правильно ставить аппаратные точки останова на чтение, запись и исполнение и немного потренируйтесь на первой попавшейся программе, дабы убедиться, что такие точки останова действительно существуют и даже работают. А потом освежите в памяти эксперимент, в котором мы при помощи дампера наблюдали изменения в коде, вызываемые командой BPX, и попытайтесь повторить его над к аппаратными точками останова. Как и следовало ожидать, аппаратные брейкпойнты не оставляют в коде программы никаких следов. Их не видно – но наши маленькие аппаратные друзья существуют и работают! И как бы программа ни утюжила свой код проверками, сколько бы ни высчитывала контрольные суммы – против аппаратных точек останова эти приемы бесполезны, так что теперь Вы сможете сколько угодно отлаживать программу, не опасаясь, что защитные процедуры в один миг изничтожат любовно расставленные Вами бряки. Интересно, что аппаратные брейкпойнты отлично работают не только с «обычными» участками памяти, но и со стеком. В принципе, ничего удивительного в этом нет – память, зарезервированная под стек, с точки зрения процессора и ОС ничем принципиально не отличается от памяти, предназначенной для кода или данных. Однако для некоторых начинающих крэкеров, разум которых опутан предрассудками, сама идея установки аппаратного брейкпойнта на чтение/запись данных в стек выглядит чем-то странным и противоестественным.
А ведь установка аппаратного брейкпойнта на стек – это отнюдь не оригинальничание, а вполне действенная техника, часто используемая при «ручной» распаковке сжатых программ, а также позволяющая обнаруживать подмену адресов возврата в стеке. Знания об аппаратных брейкпойнтах позволяют нам по-новому взглянуть на проблему подмены адресов возврата. Вспомните последний пример из предыдущей главы – тот, где функция A вызывает функцию B, функция Bвызывает функцию C, а функция Cделает «финт ушами», подменяя адрес возврата, и возвращается в функцию D (а не в функцию B). Теперь, когда Вы вооружены необходимой информацией о том, как отслеживать обращения к определенным адресам памяти, для Вас не составит труда обнаружить попытки процедуры подправить свой адрес возврата – просто «накройте» брейкпойнтом на чтение/запись двойное слово, хранящее адрес возврата, и Вы без проблем найдете команду, которая выполняет подмену. Аппаратные точки останова внутри стека могут помочь Вам победить еще один довольно неприятный защитный прием – переход со «сбросом» части стека. В вышеприведенном примере демонстрировалось искажение адреса возврата, лежащего на стеке, однако автор защиты может и не возиться с искажением адресов, а волевым решением «выбросить» со стека часть параметров, переменных и адресов возврата (это может быть сделано, к примеру, командой ADDESP,xxxx или несколькими PUSH’ами), после чего переход в нужную точку кода выполнить простым JMP. Поразмыслив над содержимым стека, Вы даже можете приблизительно определить границы стековых фреймов. Если предположить, что стековые фреймы «сбрасываются» целиком (в принципе, это не обязательно, но такой код проще в отладке), то Вы можете составить список возможных значений регистра ESP после «сброса» фреймов. Затем методом «научного тыка» протестируйте каждое из этих значений, устанавливая аппаратные брейкпойнты на чтение/запись двойного слова из каждого из этих значений ESP, а также на 4 байта ниже; можно также добавить брейкпойнты на предполагаемые адреса локальных переменных.
В результате Вы получите одну из трех ситуаций:
Однако большинство упаковщиков считают, что значение ESP лучше передавать пользовательскому коду в неизменном виде, а потому свято блюдут принцип «сколько на стек положено – столько должно быть снято», причем окончательная коррекция стека скорее всего будет проведена непосредственно перед выполнением перехода на OEP. И если эта коррекция выполняется командами чтения данных из стека (POP, POPAD и т.п.), а не простой записью в ESP ранее сохраненного значения, Ваш брейкпойнт сработает в этот знаменательный момент. Таким образом, Вам останется лишь отсеять ложные срабатывания брейкпойнта, если таковые будут, и трассировать код до тех пор, пока управление не будет передано из распаковщика в основную программу. Мой рассказ об аппаратных точках останова был бы неполон, если бы я не упомянул об одной специфической разновидности брейкпойнтов останавливающих исполнение программы при обращении к произвольной области памяти. Такие брейкпойнты в SoftIce под ОС Windows 9x создавались при помощи команды BPR(в современных версиях этого отладчика команда BPR, к сожалению, отсутствует, что особенно странно в свете того, что в арсенале OllyDebug такие точки останова имеются). Внешне использование таких брейкпойнтов ничем не отличается от работы с обычными аппаратными точками останова, если не считать возможности «накрыть» брейкпойнтами практически неограниченное количество участков памяти совершенно любого размера (что выгодно отличает данный тип брейкпойнтов от «обычных» аппаратных, которых может быть не более четырех). Гораздо больший интерес представляет знание о том, каким образом реализован этот тип брейкпойнтов, которое, возможно, пригодится Вам в будущем (к примеру, если Вы захотите написать собственный отладчик). Как Вы знаете, процессоры линейки x86 в защищенном режиме (некоторые источники называют этот режим расширенным) содержат множество средств, облегчающих создание многозадачных программ и позволяющих защитить данные от некорректных операций над ними. Для нас особенно интересной представляется возможность изменять атрибуты защиты отдельных страниц памяти, то есть разрешать или запрещать определенный тип действий (запись, чтение, исполнение кода) над информацией, хранящейся на той или иной странице памяти.
При этом любая попытка выполнить запрещенную операцию, к примеру, записать данные на страницу, для которой запись запрещена, вызовет исключительную ситуацию. Более того, чтобы ради этого нехитрого действа Вам не пришлось выбираться в нулевое кольцо защиты, в WindowsAPI включены функции VirtualProtectи VirtualProtectEx, позволяющие изменять атрибуты страниц памяти (правда, флаг запрета на исполнение кода на платформе x86 бесполезен, но необходимости в таком запрете обычно и не возникает). Как видите, создать «область останова» не так уж трудно, основную сложность в реализации таких «областей останова» представляет обработка исключительных ситуаций, возникающих при обращении к данным из защищенной области. Основным препятствием в практической реализации является то, что обычно размеры «области останова» не выровнены на границы страниц и кратны размерам страницы. По этой причине возникает необходимость в написании достаточно изощренного кода, распознающего, к какой ячейке памяти произошло обращение и обеспечивающего корректное продолжение работы программы после ошибки нарушения прав доступа к странице (по сути требуется написать нечто среднее между простым дизассемблером и виртуальной машиной). Уместно будет упомянуть, что некоторые защиты манипулируют атрибутами страниц, чтобы противодействовать отладке, memorypatching’у и снятию дампов. Большинству патчеров и дамперов эти ухищрения глубоко безразличны, однако если Вы планируете заняться написанием собственных утилит для патчинга и/или снятия дампов (что я Вам настоятельно рекомендую), обязательно проверьте, как Ваши изделия будут реагировать на нестандартные атрибуты страниц. Поэтому если у Вас возникнут какие-либо проблемы при операциях с памятью чужого процесса, есть смысл поинтересоваться атрибутами страниц, с которыми Вы работаете (это можно сделать при помощи той же VirtualAllocEx). На этом наше знакомство с теорией и практическими аспектами применения точек основа в основном закончено, и теперь мы можем во всеоружии приступить к рассмотрению методов трассировки кода.Именно этому и будет посвящена следующая глава. [C] CyberManiac Содержание Далее
Трассировка во сне и наяву.
Трассировка - одна из основ, на которых держится крэкинг. О трассировке обычно говорят вскользь как о чем-то общеизвестном и само собой разумеющемся, но при этом имеющем нечеткие, плохо формализуемые правила. Читая предыдущие главы, Вы неоднократно встречали фразы "трассируем процедуру…", "трассируйте код до тех пор, пока…" и т.п., и при этом вряд ли задумывались о том, что нужно делать и как вообще трассируют код. Так что же такое отладка - наука это или искусство? Поставив себе целью разобраться в методах трассировки, мы прежде всего должны определиться, что такое трассировка и для чего она нужна. И лишь после того, как будут определены цели трассировки, возможно будет говорить о "технической" реализации методов, посредством которых эти цели могут быть достигнуты. Прежде всего договоримся о терминологии. Само слово "трассировка" часто употребляется в различных смыслах - от синонима "отладки" до обозначения процесса пошагового исполнения программы (см. команды вроде "trace into" и "trace over" в некоторых отладчиках). Я не буду оригинален и введу еще одно значение слова "трассировка", которое и будет использоваться на протяжении данной главы: трассировка - это процесс определения "траектории" исполнения кода с некой заранее заданной точностью и наблюдение за изменениями во время исполнения этого кода. Вот такое определение - незамысловатое, но с замахом на глобальность. И, разумеется, без подробной расшифровки выглядящее достаточно туманно - как и любое другое достаточно широкое определение. Сам процесс трассировки может быть направлен на получение следующих знаний:Если Вам необходимо полное понимание некоего алгоритма (то есть "заранее заданная точность" - максимальна), Вам придется анализировать код целиком; если же Вам нужен лишь ответ на вопрос "почему программа выдает сообщение "Неверный серийный номер" вместо поздравления с регистрацией", Вас скорее всего устроит экспресс-анализ четырех-пяти точек ветвления, находящихся "выше" процедуры, сообщающей об ошибке, а все, что происходит между этими точками, Вы вольны проигнорировать. Трассировка может заключаться в поиске двух-трех точек в программе и анализе десятка прилежащих к ним команд. Как такое возможно? Очень просто: для примера в качестве критерия "заранее заданной точности" мы берем факт чтения или записи данных в некую ячейку памяти и находим в листинге все явные обращения к соответствующему адресу. После этого мы можем смело сказать "при некоторых неизвестных условиях в данную ячейку могут быть записаны значения, равные нулю либо единице". Если речь идет о глобальном "флаге зарегистрированности", то весь дальнейший взлом сводятся к выяснению, какое из состояний означает зарегистрированность программы (автор программы мог проявить оригинальность, сделав код 1 признаком отсутствия лицензии) и исправлению одного бита в программе. Иногда используется даже совсем уж вырожденный вариант трассировки: в коде программы выбирается некая контрольная точка, на нее ставится брейкпойнт, а затем при помощи тестового запуска определяется, проходит траектория исполнения программы через эту точку или нет. В основе всех приведенных примеров лежит трассировка, хотя степень детализации различается очень сильно. Каких-то готовых правил, позволяющих выбрать глубину "погружения в код", по-видимому, не существует - действовать приходится по ситуации. Однако на первых порах лучше применять "экспресс-анализ" и использовать трассировку не столько для исследования тонкостей работы кода, сколько для поиска логических блоков, поиска управляющих конструкций и понимания алгоритма в общих чертах.
Именно такой подход лежит в основе дзен-крэкинга с его принципом " я не знаю, как это работает, но я могу это сломать", а если учесть, что таким "наскоком" вполне успешно ломаются недорогие утилиты, то этот метод, несомненно, будет серьезным подспорьем для начинающего крэкера. Теперь рассмотрим, что представляет собой траектория исполнения кода. По традиции, разбирать это понятие мы будем на конкретном примере, а именно на куске кода, генерирующем таблицу констант для алгоритма CRC32: mov eax,255 _loop_i: mov edx,eax mov cl,8 _loop_j: shr edx,1 jnc @@1 xor edx,0EDB88320h @@1: dec cl jnz _loop_j mov [CRC32_Table+eax*4],edx dec eax jns _loop_i Распечатайте этот код на листе бумаги (в принципе, можно выполнять все операции мысленно, но это будет не так удобно, как работать с "твердой копией"). Возьмите карандаш и напротив каждой из команд нарисуйте кружок - это будут точки возможной траектории исполнения кода. Теперь по полученным точкам попробуем построить совокупность всех возможных траекторий исполнения кода. Как известно, большинство процессоров исполняет команды последовательно (или же успешно изображают последовательное исполнение), одну за другой, по направлению от "нижних" адресов памяти к "верхним". И такой порядок исполнения может быть нарушен лишь командами переходов/возвратов, вызова прерываний, а также различными исключительными ситуациями. Вооружившись этим знанием, предположим, что исполнение данного куска кода начинается с команды mov eax,255 и начнем соединять наши кружочки стрелками в том порядке, в каком будут исполняться команды. Когда Вы доберетесь до команды jnc @@1, у Вас наверняка возникнет вопрос, куда нарисовать стрелку - ведь для флага SF может находиться в одном из двух возможных состояний, и в зависимости от этого состояния следующей исполняемой командой будет либо xor edx,0EDB88320h, либо dec cl. Выход из этой ситуации прост - рисуйте обе стрелки. После того, как Вы закончите это упражнение, должен получиться приблизительно такой рисунок:

Что мы видим? Прежде всего - то, что в каждый кружок входит как минимум одна стрелочка, а это означает, что каждая из нарисованных нами команд получает управление явным образом. Если бы после завершения нашего высокохудожественного шедевра обнаружилось, что какой-либо кружок "висит в воздухе", это было бы веским основанием для проведения подробного расследования на тему "зачем нужен программный блок, который не получает управления явным образом" (техническую сторону таких расследований мы рассматривали в предыдущих двух главах). Далее: окинув беглым взглядом картинку, Вы легко заметите два цикла, один из которых вложен в другой и одну конструкцию ветвления, "обходящую" команду xor edx,0EDB88320h при выполнении некоего условия (в языках высокого уровня такая последовательность обычно реализуется конструкциями вида IF <условие> THEN <действия>). Вот так при помощи карандаша и бумаги можно за считанные минуты выделить логические единицы внутри довольно абстрактной процедуры. Несмотря на то, что метод кажется очень простым и даже в чем-то "игрушечным", в действительности такие схемы с кружочками-стрелочками очень удобны, особенно если Вы сравнительно недавно занялись исследованием программ, и Ваш глаз еще не натренирован на вычленение управляющих конструкций в бесконечных листингах. А если Вам не хочется слишком уж часто прибегать к помощи карандаша и бумаги, есть смысл обзавестись дизассемблером IDA - последние версии этого продукта тоже умеют проставлять стрелочки напротив кода (правда, там это реализовано не так удобно, как на нашем рисунке). Или же можно написать собственную программу, которая бы на основе листинга рисовала такие вот картинки; если не пытаться сразу создать дизассемблер (и не просто дизассемблер, а как минимум аналог W32Dasm), а анализировать уже готовые листинги, программа даже получится не слишком сложной. Надо сказать, что инструменты, способные на основе ассемблерного листинга построить картинку, подобную приведенной выше, в настоящее время очень редки и потому более чем востребованы общественностью.
Кстати, наш набор стрелочек на самом деле - отнюдь не изобретение "для личного пользования", а наглядное изображение весьма научной штуковины под названием "граф" (имеется в виду математический термин, а не дворянский титул). Так что если Вам близок раздел математики под названием "теория графов", Вы можете попробовать приложить для анализа нашей картинки всю мощь этой теории; особенно актуально это для тех, кого заинтересует тема визуализации и автоматического анализа кода. При желании можно пойти еще дальше: нарисованные кружки-команды разбить на логически связанные группы, обвести каждую группу прямоугольником или ромбиком, вписать внутрь этих фигур краткие комментарии и в итоге получить обыкновенную блок-схему (она же "flowchart" в англоязычной литературе), иллюстрирующую исследуемый код. С недавних пор IDA поддерживает и эту технику представления кода - но, как обычно, со своей "спецификой". Проще говоря, блок-схемы в IDA мало похожи на то, что обычно называется блок-схемами в учебниках информатики. Да и потому пользоваться создаваемыми в IDA графиками (которые на данный момент невозможно экспортировать ни в один формат) не так удобно, как "классическими" блок-схемами. Кроме того, на сегодняшний день блок, ответственный за отображение блок-схем в IDA, является сторонней разработкой и совершенно не интерактивен, т.е. возможности пользователя по активной работе с такими блок-схемами практически нулевые - даже чтобы написать комментарий к блоку, Вам придется распечатать схему на бумаге. Оба этих метода, рисунок из кружочков со стрелочками и блок-схема, в действительности изображают одно и то же - команды и возможные порядки их исполнения, и являются "рабочим материалом" для самого древнего метода работы с листингом - трассировки в уме. Суть метода трассировки в уме очень проста: Вы на время превращаете свой мозг в некий "виртуальный процессор", и начинаете мысленно "исполнять" команды подобно тому, как это делал бы процессор настоящий.
Надо отметить, что в старые времена, когда компьютеры были большими и медленными, а машинное время было ресурсом весьма ограниченным, именно трассировка в уме была основным способом "отладки" программ - и программисты просиживали часами над огромными листингами с карандашиками в руках, пытаясь определить, в какой точке программа уклонилась с пути истинного. Нечто подобное предстоит научиться делать и Вам - с той существенной разницей, что Вы в любой момент можете проверить свои теории при помощи отладчика. Разумеется, по сравнению с машиной Ваше "быстродействие" будет ничтожным, да и отслеживать состояние регистров и ячеек памяти у Вас вряд ли получится - но в этом и нет необходимости. Главной целью такого "мысленного исполнения кода" должно быть определение "ключевых точек", вычленение логических блоков внутри трассируемого кода, приблизительное определение назначения этих блоков и наблюдение за тем, как состояние регистров, флагов и переменных отражается на пути исполнения программы. Как я уже говорил, обычно мы изучаем не весь код построчно, а лишь те его участки, которые могут привести к интересующим нас эффектам, причем задача чаще всего стоит следующим образом: по известному эффекту необходимо найти траекторию исполнения кода, которая приводит к появлению этого эффекта. Предположим, что Вы успешно обнаружили, где в программе расположена код реализации нужного эффекта (вывод MessageBox'а, запись единицы в регистр EAX и т.п.), и Вам хочется понять, каким образом программа передает управление на этот код и каким образом этого можно избежать или наоборот - получать такой результат при любых исходных данных. Для решения таких задач обычно используется обратная трассировка в уме. Идея обратной трассировки очень проста: мы начинаем читать листинг "задом наперед", то есть движемся от следствия (которое нам известно) к причине. По ходу дела отмечаем ключевые точки, к которым относятся:
Действительно, если Вы видите вызов API' шной функции чтения командной строки, для Вас будет очевидно, что следующие за вызовом команды почти наверняка будут оперировать именно с текстом командной строки, а не с фазами Луны или курсом доллара. И вот тому пример: call GetCommandLineA mov edi, eax cmp byte ptr [edi], 22h jnz short loc_401B08 Нетрудно догадаться, что этот кусок кода проверяет, является ли первый символ командной строки двойной кавычкой. Далее, согласно принципам дзен-крэкинга, следует помедитировать о том, что может последовать за такой проверкой. А за ней обычно следует поиск закрывающей кавычки и дальнейший синтаксический разбор строки - пропуск разделяющих пробелов, вычленение нужного параметра из строки (обычно определяется смещение первого символа в строке и длина этого параметра) и т.п. Нечто подобное как раз и проделывал тот патч, из которого я взял код для примера. Для нас в данном случае интересен не подробный анализ кода, я хотел продемонстрировать несколько иное, а именно: как всего лишь один известный системный вызов позволяет приблизительно оценить назначение процедуры, в которой он содержится. Причина интереса к глобальным переменным тоже достаточно очевидна. Современные программисты, как правило, придерживаются принципов структурного и объектного программирования, которые предполагают минимальное использование общедоступных объектов - каждая процедура должна "видеть" лишь те данные, которые ей нужны для работы. Поэтому разработчики программ обычно дают глобальный статус двум типам данных: тем, которые используются настолько широко, что их неудобно передавать в каждую процедуру, где они требуются (например: настройки программы, таблицы констант, тексты сообщений и т.п.), и всевозможным отладочным переменным, которые полагается удалять при выпуске релиза программы. Регистрационные данные нередко занимают промежуточное положение: с одной стороны, это своеобразная "настройка программы", а с другой они близки к отладочной информации в том смысле, что добавление защиты нередко производится уже после написания программы чисто механическим путем ("если переменная не равна нулю, то нарисовать поверх отчета слово UNREGISTERED"). Как Вы понимаете, использование глобальной переменной для хранения статуса программы в наше время - редкая и счастливая для крэкера случайность.
Гораздо чаще разработчики, начитавшись руководств "как защитить свою программу от хакеров за 1 час", усваивают, что глобальную переменную использовать в качестве "переключателя" нехорошо. Вот функция, вызываемая к месту и не к месту - это совсем-совсем другое дело. Сказано-сделано, и в программе появляются многочисленные куски вида "if (!RegistaProggie()) ShowMessage ("Wanna getta munnee!")" (если кто не понял текст сообщения, приблизительный перевод с нетрадиционного английского звучит как: "хочу бабки!"). А во что такие куски превращаются при ассемблировании? Правильно - в цепочку CALL-CMP-Jxx, о которой я говорил парой абзацев выше. Вот такие-то интересные кусочки мы и будем высматривать при обратной трассировке. Разумеется, одним лишь поиском регистрационных процедур дело не ограничивается - цепочки "вызов-проверка-ветвление" могут быть проверкой на корректность введенных данных (как-то раз мне пришлось поправить программу игры "Жизнь", которая могла, но отчего-то не хотела работать с полями больше, чем 100*100), и конструкцией SWITCH (она же CASE в Паскале), да и много чем еще. Причем, если у функции есть параметры, они могут стать отличной подсказкой, позволяющей установить назначение этой функции (особенно хорошо этот прием работает с функциями, выполняющими преобразование строк). Для этого нужно под отладчиком исследовать, что именно передается в функцию и какой результат она возвращает. Нередко это даже оказывается проще, чем догадаться, что хранится в глобальной переменной. Но вернемся к описанию алгоритма обратной трассировки в уме. Итак, мы встретили команду ветвления или перекрестную ссылку. Переходим к точке, куда/откуда ведет ссылка, а затем пытаемся определить, при каких условиях эта ссылка "срабатывает" и что происходит, когда условие срабатывания ссылки не выполняется, причем для "не сработавшей" ветки исходник нужно читать уже не "снизу вверх", а в порядке исполнения команд.
Следуя этой схеме, Вы, скорее всего, доберетесь до начала либо до конца процедуры, если не запутаетесь во всех этих ветвлениях и переходах. По сути, алгоритм обратной трассировки в уме рекурсивен (команды условного перехода или перекрестные ссылки часто порождает два возможных пути чтения кода), а человеческое сознание мало приспособлено к выполнению рекурсивных алгоритмов "вручную", так что Вам лучше сначала потренироваться на простых примерах. Добравшись до начала процедуры либо до точки выхода из нее, Вам придется либо анализировать все возможные варианты, откуда и почему могла быть вызвана процедура (т.е. пробежаться по всем ссылкам на эту процедуру), либо прибегнуть к отладчику. Второй путь, конечно, нарушит "чистоту идеи" трассировки в уме, но зато даст ответ максимально простым и быстрым способом. Теоретически процесс обратной трассировки в уме Вы можете продолжать до тех пор, пока не доберетесь до начала программы, но на практике мне редко приходилось подниматься по "дереву процедур" более чем на четыре уровня вверх. Нельзя не отметить одну тонкость: даже если Вы нашли точку выхода из процедуры, убедитесь, что эта точка единственная: компиляторы могут генерировать код с более чем одним выходом из процедуры\функции, причем эти "дальние" ветки могут быть даже более интересными, чем "ближние". Дело в том, что любая проверка может быть многоступенчатой: к примеру, сначала проверяется, введены ли вообще какие-либо данные, затем - насколько эти данные корректны, и лишь в последнюю очередь - соответствуют ли эти данные какому-либо узкоспециализированному критерию. Трассируя программы, Вам придется немало поводить пальцем по распечатке или экрану дисплея, взбираясь по дереву вызовов и переходов. А если к тому же Ваш дизассемблер "не умеет" выделять цветом команды переходов, Вы можете запросто пропустить что-нибудь важное. В общем, блуждание по бескрайним полям кода - дело, требующее внимания, сосредоточенности, и при этом достаточно утомительное.
Однако если Вы решаете типовую задачу "как заставить программу выполнить некий код независимо от правильности исходных данных", Вам может помочь все та же карта всех возможных путей исполнения кода, построение которой я демонстрировал в самом начале статьи. Как только Вы составите такую карту, Вам останется лишь выполнить три несложных действия:
И никто не может - поскольку при трассировке в уме Вам неизвестны исходные данные, которые и заставляют программу выбирать из всех возможных путей единственный актуальный. Именно здесь и пролегает граница между разглядыванием препарированного "мертвого" кода и вождения пальцем по распечатке и наблюдением за "живой" программой, обрабатывающей "настоящие" данные. Так что пришло время поговорить о методах трассировки программ "вживую". Исторически первым способом трассировки "вживую" был многократный вызов в отладчике функции "исполнить текущую команду" с заходом в подрограммы или без такового. Проще говоря, программист сидел перед монитором и давил нужную кнопку, наблюдая, как бегает по коду курсор, и меняются значения регистров. Более продвинутые (или просто более ленивые) программисты смекнули, что для определения траектории важны не все команды, а лишь те, в которых программа делает выбор, в какую сторону ей "свернуть". А потому для определения траектории нужно наставить брейкпойнтов на команды условных вызовов и переходов, а все, что находится между этими командами, можно исполнять в автоматическом режиме. Интерес программиста был прост: наблюдая за траекторией "забега" программы, отследить момент, когда программа уклонится в неправильную сторону, а потом найти причину отклонения и исправить программу так, чтобы программа "бегала" по предназначенному ей пути. Обычно отслеживание траектории исполнения по "ключевым точкам" использовалось для получения ответов на вопросы "после какой точки программа начинает работать некорректно", а пошаговое исполнение - чтобы точно определить, какие именно команды формируют неверные данные. И методы эти за все годы, прошедшие со времен их появления, ничуть не утратили актуальность и широко применяются в практически неизменном виде. Технический прогресс не стоял на месте: расставлять брейкпойнты и гонять по ним программу вручную было неудобно, и программистам не могла не прийти в голову мысль "а почему бы не усовершенствовать отладчик таки образом, чтобы собственно трассировка выполнялась в автоматическом режиме".
Прообразом современных средств трассировки был режим "анимации" (то есть замедленного исполнения) программы - при некотором навыке в мелькании содержимого регистров и переменных можно было попытаться отловить полезную информацию и "притормозить" программу в нужный момент. Впрочем, пользы от этого режима в те времена было немного - изучать в режиме анимации сложные программы было неудобно, а анимировать программы, работающие с графикой - и вовсе невозможно (в те времена отладчики функционировали исключительно в текстовом режиме). Однако в некоторых современных отладчиках режим анимации все-таки сохранился, и, надо отметить, толку от него куда больше, чем во времена DOS. Значительно возросшее быстродействие современной техники превратило анимацию из слайд-шоу для особо терпеливых, сопровождавшегося лихорадочным мерцанием экрана, в весьма динамичное действо, наблюдение за которым доставляет лишь удовольствие. Некоторые люди считают, что исполнение кода в режиме анимации никакой практической пользы не приносит, но я придерживаюсь несколько иного мнения: в этом режиме хорошо заметны длинные циклы, а также циклы со счетчиком, особенно если счетчик расположен в одном из регистров. Начинающим, думаю, интересно будет "вживую" понаблюдать работу распаковщика исполняемых программ - лучше один раз увидеть работу несложной процедуры распаковки, чем сто раз прочитать "книжное" описание этого процесса. Для просмотра этого шоу лучше всего взять небольшую программу, сжатую UPX'ом, поскольку "навесная" процедура распаковки UPX - одна из самых простых, и даже если у Вас не получится полностью разобраться в ней при помощи отладчика/дизассемблера, все сложные моменты можно разъяснить по исходным текстам, находящимся в свободном доступе. Читая эту главу, Вы могли заметить, что многие из описанных методов, несмотря на всю их внешнюю простоту, довольно неудобны для практического применения, поскольку требуют от пользователя отличной памяти и внимательности, а также способностей к рекурсивному "чтению" кода.
Попробуйте "пробежаться" отладчиком по достаточно сложной процедуре, при этом строя траекторию исполнения кода в уме. Если у Вас получилось - значит, либо процедура оказалась не очень сложной, либо большинству людей остается лишь позавидовать Вашей памяти. Так или иначе, но программисты пришли к идее автоматической трассировки, то есть пошагового исполнения программы с одновременным "запоминанием" всех сделанных шагов. Основным препятствием на пути к осуществлению этой идеи долгое время был сравнительно небольшой объем оперативной памяти старых ЭВМ и недостаточная мощность процессора. Даже такой старый процессор, как 8086, способен был выполнять десятки и сотни тысяч команд в секунду - представьте, какой объем памяти потребовался бы, чтобы запомнить всего лишь последовательность адресов исполненных команд, не говоря уже о состоянии регистров. Кроме того, на одну команду, выполненную отлаживаемой программой в режиме трассировки, приходятся десятки и сотни команд, выполненных отладчиком - и отсюда возникает заметное падение производительности трассируемой программы. В общем, до некоторого времени реализация такого способа трассировки машинного кода была практически нереальна. Однако когда объем памяти компьютеров начал измеряться мегабайтами, воплощение этой идеи наконец стало возможным. Режим трассировки появился сначала в SoftIce, а затем и в OllyDebug, причем по возможностям трассировки и связанных с ней функций OllyDebug определенно превзошел все прочие известные автору отладчики. Прежде всего следует отметить, что OllyDebug, в отличие от SoftIce, запоминает трассировочную информацию более "интеллектуально" - то есть сохраняет не только список адресов команд, но и модификации регистров. К сожалению, хранить информацию обо всех изменениях в адресном пространстве процесса OllyDebug не может (это потребовало бы совершенно невообразимого объема ОЗУ), но если обращение к переменной производится по указателю на нее, отладчик вполне способен запомнить значение регистра-указателя.
Такое поведение отладчика облегчает задачу отслеживания состояний регистров: если Вас интересует, в какой момент том или ином регистре "появилось" некое число, Вы можете решить эту задачу простым поиском этого числа в текстовом файле. Да-да, именно в текстовом файле - OllyDebug обладает совершенно уникальной на сегодняшний день возможностью сохранять практически любые промежуточные данные из отладчика на жесткий диск, и в число таких данных входит отчет об исполнении программы в режиме трассировки. Вы можете безо всяких ухищрений сохранить список всех исполненных команд, их адресов, а также всю дополнительную информацию об изменениях в регистрах. Пример такого отчета Вы можете увидеть ниже: Address Thread Command Registers and comments Flushing gathered information 01006AEC Main xor ebx, ebx EBX=00000000 01006AEE Main push ebx pModule = NULL 01006AEF Main mov edi, dword ptr ds:[<&KERNEL32.GetModuleHandleA>] EDI=77E7AD86 01006AF5 Main jnz short NOTEPAD.01006B16 EAX=01000000 01006AF7 Main mov ecx, dword ptr ds:[eax+3C] 01006AFC Main add ecx, eax 01006AFE Main cmp dword ptr ds:[ecx], 4550 ECX=000000E8 01006B01 Main jnz short NOTEPAD.01006B15 ECX=010000E8 Как распорядиться столь подробным отчетом - зависит только от Ваших целей и изобретательности. Я могу лишь подсказать самые общие направления поиска. Прежде всего, стоит проанализировать значения, находящиеся в первой колонке, то есть адреса команд и "наложить" список адресов выполненных команд на листинг дизассемблирования программы (для W32Dasm, который формирует этот листинг в виде текстового файла, технически осуществить это несложно). Такое слияние "мертвого" листинга с отчетом отладчика о работе "живого" кода способно значительно облегчить понимание того, в какой точке программа повела себя "не так", и как наставить ее на путь истинный. Также бывает удобным "спроецировать" конкретную траекторию исполнения кода на схему всех возможных путей исполнения кода (например, обвести часть стрелочек красным карандашом) - сочетая этот прием с патчингом кода и/или модификацией данных для принудительной активации тех или иных траекторий, можно последовательно отсеивать траектории, не ведущие к желаемой цели. Здесь следует сделать небольшое отступление и рассказать об одном принципиальном преимуществе текстового представления дизассемблерных листингов перед всевозможными упакованными двоичными форматами.
Текст - это один из самых старых и универсальных способов передачи информации, и для его обработки создано огромное количество всевозможных утилит. Инструменты работы с текстовыми файлами сами по себе очень стары, но, тем не менее, практически не устаревают, алгоритмы, в них использующиеся, "вылизаны" и доведены до совершенства поколениями программистов, и потому было бы неразумно отвергать столь огромный пласт программистской культуры. Конечно, использование узкоспециализированных двоичных форматов в дизассемблерах позволяет сэкономить дисковое пространство, ускорить обработку данных и хранить вместе с листингом различную дополнительную информацию, например, списки перекрестных ссылок, но за это приходится расплачиваться неоправданной интеграцией собственно дизассемблера и средства просмотра дизассемблированного текста. А просмотрщики дизассемблерных листингов, увы, обычно проектируются по остаточному принципу. Если рассматривать наиболее популярные в настоящее время W32Dasm и IDA Pro, то можно обнаружить не всегда удобную навигацию, ориентацию на исключительно текстовый режим работы (как я уже говорил, построение блок-схем в IDA Pro реализовано в виде программы сторонних разработчиков, не поддерживающей интерактивную работу с кодом), и некоторые другие недостатки. Авторы дизассемблеров, впрочем, вполне осознают недостатки двоичных форматов и полезность прямой работы с текстом, а потому предусмотрели возможность экспорта в текстовый формат и даже "обычного" текстового поиска в окне дизассемблера. В IDA Pro реализован даже более сложный вариант поиска с использованием регулярных выражений и специальный скриптовый язык, который по идее должен дать пользователю возможность самому добавить в дизассемблер недостающую функциональность. Однако хороший просмотрщик текстов, набор двоичных утилит *NIX'ового происхождения и навыки в программировании способны творить с "сырыми" листингами такие чудеса, какие традиционным дизассемблерам и не снились - от простого просмотра с подсветкой синтаксиса до форматирования "лесенкой" текстов на ассемблере.
А поскольку наш отчет о трассировке как раз имеет текстовый формат и является, по сути, специфической формой дизассемблерного листинга, к нему вполне применимы все изложенные выше соображения о работе с текстом. Поиск повторяющихся последовательностей адресов позволяет обнаружить программные циклы, а также узнать, сколько раз эти циклы выполнились. Ну и, разумеется, Вы сможете ответить на вопрос, который начинающие весьма часто задают, но на который весьма редко получают ответ. Вопрос этот такой: "как мне узнать, когда в регистре появляется нужное число?" (иногда встречалась еще более странная формулировка: "как установить брейкпойнт на регистр"). Простой поиск нужного числа в тексте отчета позволит Вам ответить на этот вопрос, хотя полезность такого действия в большинстве случаев весьма сомнительна (по крайней мере, у меня ни разу не возникало такой потребности). Увы, в каждой бочке меда есть своя ложка дегтя, а в нашем случае - даже не одна. При пошаговой отладке (а, стало быть, и в процессе трассировки - тоже) накладные расходы на исполнение одной команды в десятки и сотни раз превышают собственно время исполнения команды, из-за чего скорость исполнения кода в режиме трассировки падает катастрофически. О расходах памяти на хранение информации о порядке исполнения команд я уже упоминал, однако это еще не все: если внутри отладчика эта информация подвергается упаковке для более компактного хранения, то когда Вы сохраняете эту информацию на диск в виде текста, результирующий файл может получиться просто огромным. И он будет тем огромнее, чем большее количество команд было исполнено в режиме трассировки. Так что в ближайшем будущем нам, увы, не светит возможность запустив программу под отладчиком в режиме трассировки, беспрепятственно снимать с нее "жизненные показатели", выискивая подозрительные значения регистров и "нехорошие" команды переходов. Трассировка - это метод, пригодный для анализа сравнительно небольших кусков кода, чего, впрочем, обычно более чем достаточно.Также есть принципиальные проблемы с трассировкой программ, активно работающих с "железом", многопоточных программ и с софтом, использующим коммуникацию между процессами - все эти группы ПО, даже не содержащие защит, во время трассировки могут вести себя очень капризно. [C] CyberManiac Содержание Далее
Почти начинаем ломать.
Мы откроем сундук, хотя бы пришлось из-за него умереть... Р.Л. Стивенсон, «Остров сокровищ» Итак, допустим, что у нас есть программа и в ней содержится вредоносный код (далее - «защита»), который нужно обезвредить. Инсталляционный файл тихо лежит на нашем винчестере, ожидая того знаменательного момента, когда мы его запустим, чтобы извлечь на свет скрытую в его недрах программу. Мне вполне понятно Ваше желание немедленно установить эту программу и вступить в битву со злобным и коварным врагом, но охладите на несколько минут свой пыл, и послушайте мой рассказ о процессе инсталляции программ, и о том, чем этот процесс может кончиться. Итак, многие программы в настоящее время поставляются в виде инсталляционного пакета. Для установки программы, как правило, требуется либо запустить один из файлов пакета (это, в частности, отлично всем известные Setup.exe), либо открыть при помощи другой заранее установленной программы (к примеру, файлы с расширением MSI, созданные Microsoft - овским инсталлятором или RPM-пакеты в Linux). В инсталляционных пакетах кроме самих файлов, которые требуется установить на машину пользователя, содержится также описание сценария инсталляции в том или ином виде (назовем это описание сценария для простоты «инсталляционным скриптом», тем более, что чаще всего так оно и есть). Разумеется, инсталлятор может быть и обычной программой, написанной для установки конкретного приложения, но написание собственного инсталлятора – дело достаточно трудоемкое, и поэтому на практике такие инсталляторы встречаются весьма редко. Инсталляционный скрипт описывает, в каком режиме устанавливается тот или иной файл (добавление, замена, замена с предварительной проверкой версии и т.п.), какие данные необходимо внести в реестр или файлы конфигурации, какие программы запускаются до, в процессе и после инсталляции, а также многое другое. В это «многое другое» могут входить и такие безусловно интересные вещи, как проверка серийных номеров, создание записей в реестре, а также распаковка и запуск программ.О встроенных в инсталляционный скрипт серийных номерах мы поговорим позже. А пока попробуем поразмышлять о том, в чем может заключаться опасность неконтролируемого создания ключей в реестре или запуска каких-либо программ. Если Вы взламываете какую-либо программу, оснащенную ограничением на время использования или число запусков, один из «корней зла» может гнездиться именно в инсталляционном скрипте. Представьте себе такую ситуацию: программа хранит дату первого запуска и/или какую-либо иную информацию, необходимую для проверки на истечение срока пробного использования, в реестре. Разумеется, информация закодирована, предприняты меры, чтобы защиту не обманывало «подкручивание» системной даты, возможно даже соответствующий ключ реестра хорошо замаскирован (Вам ничего не напоминает мое описание? Да это же ASProtect – ломаный - переломанный, но, как ни странно, все еще популярный). Тем не менее, одна лазейка все-таки осталась – если триальный ключ в реестре отсутствует, защита считает, что раньше программа не запускалась. Поэтому защиту можно обмануть, просто удалив из реестра лишние ключики. А теперь представьте, что триальный ключ создается в процессе инсталляции, и если он отсутствует, программа просто перестает запускаться. Если Вы ставите целью взлома ликвидацию триальных ограничений, Вам могут потребоваться весьма значительные усилия, чтобы отыскать этот маленький, но зловредный ключик в огромном реестре еще более огромной Windows. Как вам такая перспектива? Если встроенных средств инсталлятора оказывается недостаточно для создания триальной «метки», это можно сделать при помощи небольшого исполняемого файла, который распаковывается в процессе инсталляции, запускается, делает свое черное дело и сразу после этого удаляется. Упрощенный вариант этого приема выглядит как автоматический запуск приложения после инсталляции, чтобы защита смогла создать триальные «метки» на компьютере пользователя. В качестве метки, на основе которой программа будет проверять ограничения по времени или числу запусков, также может использоваться какой-либо файл, создаваемый в процессе инсталляции, особенно если этот файл запрятан в глубинах одной из системных директорий (да-да, я знаю, что мусорить в системных директориях нехорошо; осталось лишь объяснить это некоторым авторам защит) и при этом еще активно используется программой.
Я встречал защиту, основанную на подобном принципе: дата создания одной из DLL использовалась для отсчета времени с момента установки, а сама DLL лежала в системной директории Windows среди сотен себе подобных и динамически подгружалась основной программой. Какие средства мы можем применить, чтобы обнаружить и обезвредить эти и другие подобные приемы? Наиболее радикальным средством, разумеется, является декомпиляция инсталляционного скрипта – в этом случае мы получаем практически полную информацию о том, что происходит в процессе установки программы, а в некоторых случаях даже можем повлиять на этот процесс, внеся исправления в инсталлятор. Разобрать инсталляционный скрипт «по косточкам» - задача не самая простая, да и не всегда это необходимо. Поэтому на практике чаще пользуются другим типовым приемом, позволяющим обнаружить произошедшие изменения. Этот прием заключается в использовании утилит мониторинга, делающих «снимки» системы (реестра, размеров и дат создания/модификации файлов) до и после установки и затем анализирующих различия между снимками. Подробный журнал изменений, выдаваемый такими программами, позволяет легко обнаружить подозрительные ключи и файлы, появившиеся в процессе инсталляции. Забегая вперед, скажу, что такие же «снимки» рекомендуется делать и при прохождении других критических периодов работы программы о которых мы поговорим в следующей главе. Установить факт запуска каких-либо программ во время инсталляции можно при помощи утилит, отслеживающих создание и завершение процессов. Однако деятельность программ, запускаемых в процессе инсталляции, не ограничивается одним лишь созданием триальных ключей. Дело в том, что набор функций, поддерживаемых инсталляторами, ограничен и некоторые действия (например, проверку серийного номера с использованием достаточно сложного алгоритма) выполнить средствами инсталляционных скриптов не всегда возможно. Один из приемов, применяемых в этом случае – запуск исполняемого файла, который и выполняет все необходимые операции, а потом тем или иным образом возвращает результат проверки в инсталлятор - существуют защиты, в которых проверка серийного номера реализована именно так.
В некоторых инсталляторах для этих же целей предусмотрен интерфейс, позволяющий использовать плагины (плагин в виде динамически загружаемой библиотеки также упрятываются внутрь инсталлятора, в нужный момент распаковываются во временную директорию и после использования удаляются). Такие исполняемые файлы и плагины, разумеется, невозможно модифицировать напрямую и чаще всего не удается извлечь из инсталлятора для дизассемблирования и изучения, т.к. они хранятся внутри инсталлятора в сжатом виде, а большинство коммерческих инсталляторов несовместимы по формату с обычными архиваторами. Если Вы хотите исследовать такой исполняемый файл, Вам почти наверняка потребуется снять с него дамп, чтобы получить материал для загрузки в дизассемблер. Сделать это совсем несложно – запустите инсталлятор под отладчиком и поставьте точки останова на все функции, связанные с загрузкой модулей и библиотек (для плагина) или создания процесса (дляEXE). ВWindows это будут LoadLibrary[Ex], LoadModule[Ex], CreateProcess или устаревший WinExec соответственно. Запоминаем, откуда инсталлятор пытается загрузить файл, и затем «замораживаем» работу инсталлятора непосредственно перед исполнением этой функции патчем MySelf:jmpMySelf либо манипуляциями с атрибутами соответствующего процесса и потока (например, применив к нему функцию SuspendThread). Затем копируем нужный нам файл в надежное место и делаем с ним все, что только придет нам в голову. Если такой файл, запускаемый во время инсталляции, работает сколь - нибудь длительное время (достаточное, чтобы обнаружить факт появления нового процесса и записать данные в его адресное пространство), Вы даже можете создать memorypatch, позволяющий модифицировать код этого файла в памяти. Приведу практический пример: одна из программ в процессе установки запрашивала пароль, который было необходимо ввести, чтобы продолжить инсталляцию. Путем экспериментов удалось установить, что инсталлятор создавал в директории для временных файлов исполняемый файл со случайным именем, запускал его и затем блокировал доступ к файлу даже на чтение, после чего собственно ввод пароля и его проверка осуществлялась уже средствами этой запущенной программы.
Заставить программу «признать» любой пароль при помощи правки кода программы в SoftIce не составляло особой сложности, но вот изготовить «классический» патч, который позволил бы устанавливать эту программу, не прибегая к помощи отладчика, оказалось крайне затруднительно. Но, поскольку ввод пароля осуществлялся в стандартном окне Windows, это дало возможность подойти к проблеме с другой стороны. После недолгих размышлений выяснилось, что можно создать программу-launcher, выполняющую следующие действия:
Существует три метода проверки серийного номера:
Это позволит Вам сравнительно быстро узнать технические возможности инсталлятора (и проблемы, которые эти возможности могут Вам создать), структуру инсталляционного скрипта, способ генерации и формат хранения правильных серийных номеров, коды и функции внутренних команд интерпретатора скриптов. При желании Вы даже сможете написать распаковщик инсталляционных пакетов, декомпилятор инсталляций а то и универсальный(!!!) генератор ключей ко всем программам, инсталляционные пакеты которых сделаны при помощи исследованного Вами программного продукта. В некоторых случаях наиболее прямым путем к получению полнофункциональной программы из урезанного варианта является именно вскрытие инсталлятора. Поясню эту идею примером. Допустим, что у Вас есть демо-версия какой-либо хорошей программы, но Вам этого мало, и Вы хотите иметь полностью функциональный вариант этого продукта. При этом у Вас нет никакого желания вручную восстанавливать недостающий код, заботливо выдранный автором. Однако на сайте производителя иногда можно найти обновления для коммерческих версий нужного Вам софта, и эти обновления, как правило, содержат исправленные варианты основных файлов программы. Что из этого следует? А то, что если неким таинственным образом Вам удастся установить апдейт на демо-версию, есть ненулевая вероятность, что свою демонстрационную версию Вы превратите в программу, по функциональности практически не отличающуюся от полной! Разумеется, программа обновления перед своей установкой выполнит проверку на возможность обновления (в том числе и для того, чтобы пользователь случайно не «обновил» демо-версию) – но ведь Вы решили изучать крэкинг именно для решения проблем такого рода. Так что Вам потребуется лишь немного «доработать» инсталлятор, ликвидировав в нем проверку на возможность обновления. По сути, любой инсталлятор представляет собой самораспаковывающийся архив с достаточно сложным SFX-модулем. Более того, некоторые инсталляционные пакеты даже можно открыть обычными архиваторами! Прямым следствием этого является возможность распаковать содержимое инсталляционного пакета (если, конечно, оно не зашифровано).
Даже если ни один из стандартных архиваторов «не берет» инсталляционный пакет, распаковать файлы можно вручную. Дело в том, что инсталлятор обязательно содержит внутри себя процедуру распаковки, и эта процедуру можно обнаружить и проанализировать. Вам понадобится узнать, где находится процедура распаковки, какие параметры принимает, и что эти параметры обозначают (хотя бы приблизительно). Если Вам это удастся, Вы сможете попытаться принудительно вызвать эту процедуру с нужными Вам параметрами, манипулируя кодом программы и состоянием регистров, и извлечь файлы из пакета. Задача поиска этой процедуры облегчается тем, что в инсталляционных скриптах указание на место, куда будут устанавливаться файлы, хранится в виде текста и, поставив точку останова на начала этих текстовых строк, Вы сможете обнаружить, откуда происходит обращение к этим строкам. Вообще, даже простой анализ текстовых строк, содержащихся в инсталляционном пакете, способен дать множество полезной информации. А теперь представьте себе программу, которая при установке просит некоторое условие, и, если это условие не выполнено, «забывает» установить пару-тройку файлов или устанавливает вместо нормальных версий этих файлов урезанные. Вот тут-то и Вам и пригодится возможность заглянуть внутрь архива и извлечь из него нужные файлы. [C] CyberManiac Содержание Далее
Критические дни» программ.
Все боится времени, но само время боится Пирамид Египетская пословица После того, как мы успешно установили программу, неизменно наступает торжественный момент первого запуска программы (конечно, если инсталлятор не испортил нам удовольствие, запустив программу автоматически). Немало программ содержат ограничения на время пробного использования, и потому необходимо со всей ответственностью отнестись к первому (да и не только к первому, как мы увидим в дальнейшем) запуску программы. Дело в том, что процесс исследования защиты может затянуться и Вам элементарно не хватит тех дней, которые отведены под пробное использование программы. Вы, разумеется, захотите продлить время пользования программой – и вот тут Вас может ожидать весьма неприятный сюрприз. В общем случае задачи по неограниченному продлению триального времени программы обычно не представляют особой сложности, но в качестве дополнительных уровней защиты часто присутствуют всевозможные навесные защиты, ликвидация которых может потребовать гораздо больших трудозатрат. Поэтому надо, по возможности, предупреждать подобные ситуации, а о том, каким образом эти ситуации могут возникать, я и расскажу в этом разделе. Возможны и другие причины, по которым Вам может потребоваться снять ограничение на время использования программы. Для некоторых программ процедура регистрации не предусмотрена в принципе, но при этом в течение испытательного срока они выполняют свои функции в полном объеме (часто такие программы помечены как Demo- и Evaluation-версии), и вполне приемлемым решением было бы неограниченное продление триального срока. Случается также, что необходимо установить бета-версию программы, у которой истек «срок годности», но более новой беты нет, а посмотреть на программу очень хочется. Так или иначе, снятие ограничений на время использования или число запусков программ – одна из актуальных задач современного крэкинга. Представьте себе, что Вы – автор программы, и Ваша задача – ограничить «испытательный срок», в течение которого пользователь может работать с программой.Как такое можно реализовать? Выбор мест хранения информации об испытательном сроке в современных ОС довольно небогатый – содержимое некоего файла (который можно попытаться спрятать), атрибуты файлов, либо системный реестр (это актуально только для ОС Windows). Такие изменения могут быть обнаружены при помощи утилит, умеющих создавать и сравнивать снимки состояния системы. Изредка встречаются нетрадиционные решения вроде манипуляций на уровне файловой системы или использования слэков. Использование слэков для защиты основано на том, что последний кластер, занятый файлом, обычно заполнен не целиком, и потому в незаполненной (и невидимой для большинства программ, оперирующих с файлами) можно хранить некоторый объем информации. В принципе, идеологических различий между реестром Windows, файлами и атрибутами отдельных файлов нет, все эти объекты могут использоваться для хранения используемых защитой данных. Действительно, аналогии в устройстве реестра и дисковой подсистемы очевидны: «ветви» реестра играют роль логических дисков, разделы – практически полные аналоги папок (убедиться на наглядном примере можно, взглянув на иконки разделов в «Редакторе реестра»), имена ключей – это имена файлов, а значения ключей – содержимое этих файлов. Аналогии можно продолжить, но для нас важно другое: поскольку файловая система подобна реестру, принципы поиска и ликвидации защитных механизмов, основанных на сокрытии информации на дисках или в реестре, во многом будут сходны. Поэтому далее я буду говорить в основном о реестре, оставляя читателю самому разобраться в том, как аналогичные механизмы могут быть реализованы на основе файловой системы (или почему они не могут быть реализованы). Конечно, все разнообразие механизмов ограничения времени использования программы или количества запусков охватить нереально, но в этом и нет необходимости – воспользовавшись своим воображением, Вы легко продолжите список. Я лишь перечислю основные события в «жизни» программы, и их возможную связь с реализацией триальных механизмов в программах. Первое, что приходит в голову – во время первого запуска сохранить в файле или в реестре дату (или вычислить дату, после которой программа не должна работать) и при каждом запуске сравнивать текущую дату с сохраненной, проверяя, не истек ли «срок годности» программы.
Такие программы, не обнаружив в реестре пометки о дате первого запуска, как правило, считают, что текущий запуск – первый. Такое простейшее решение, как правило, и обходится простейшими средствами – достаточно обнаружить и удалить соответствующий файл или ключ реестра. Одна из модификаций этого метода – определение даты первого запуска через чтение атрибутов файлов: при создании любого файла атрибут CreationTime (дата создания файла) устанавливается автоматически, что позволяет непосредственно в процессе инсталляции «промаркировать» все устанавливаемые файлы датой установки программы. Затем программа просто проверяет при каждом запуске дату создания какого-либо файла или папки (или вообще всех файлов, созданных в процессе инсталляции) и на основе этой информации вычисляет количество дней до истечения испытательного периода. Что интересно, свойства файлов могут использоваться не только для определения даты установки программы, но и для определения текущей даты: в процессе своей работы отдельные компоненты ОС нередко ведут всевозможные «журналы» (логи) в файлах с заведомо известными именами. Проверив дату последней модификации такого файла или его содержимое, можно с некоторой погрешностью узнать текущую дату. Как было показано выше, надежность такого метода довольно низкая и защита ориентирована в основном на психологический эффект. Более устойчивые варианты этой защиты основаны на использовании «меток», оставляемых программой в реестре в некоторые критические моменты. Метки, создаваемые при инсталляции, обычно предназначены для предотвращения повторной установки программы (и получения дополнительного триального срока) и для противодействия попыткам «сбросить» счетчик времени удалением ключа реестра. Если инсталлятор не позволяет выполнять сложные вычисления внутри инсталляционного скрипта, метка может выглядеть как запись в реестре с фиксированным значением, наличие которого будет проверяться при первом запуске. Если для создания инсталляционного пакета использовался достаточно мощный продукт, дата инсталляции или максимальное число запусков программы может быть прописано в реестре уже в процессе установки (возможно, в зашифрованном виде).
Кроме того, инсталлятор может проверить наличие сделанных ранее «меток» в реестре для предотвращения повторной инсталляции. Таким образом, гарантированно вернуть программу в рабочее состояние после окончания триального срока можно только удалением всех «меток», оставленных программой, с последующей переустановкой. Многие из ныне существующих защит создают «метки» в момент первого запуска программы. Факт первого запуска обычно определяется простейшим способом: если программа при запуске не обнаруживает «метку», она считает, что запущена впервые, и создает «метки». Обычно создание «меток» выполняется сразу после начала исполнения программы, но изредка встречаются программы, которые выполняют эти действия при завершении программы (возможно, что существуют защиты, которые делают это в случайный момент времени). Скорее всего, таким образом разработчики защит пытались затруднить выявление защитного кода, но на деле они добились прямо противоположного эффекта: большинство операций по загрузке данных из реестра выполняется именно при запуске или изменении настроек программы, а вот запись в реестр в конце сеанса работы – операция менее распространенная. При завершении сеанса обычно сохраняется только информация о положении окон, настройках интерфейса программы и прочие подобные данные, которые, как правило, несложно отличить от защитных механизмов программы. Очевидно, что если защита использует исключительно «метки», создаваемые при первом запуске, после удаления этих «меток» программа считает, что запущена впервые, и начинает отсчет триального срока заново. Для того, чтобы пользователи не обходили эту защиту совсем уж элементарными средствами вроде удаления соответствующего ключа реестра, ключ, содержащий в себе защитную информацию, может быть «плавающим», то есть имя ключа может генерироваться случайным образом в зависимости от аппаратной конфигурации компьютера или каких-либо иных параметров. Найти вручную такой ключ среди десятков подобных практически нереально. В частности, именно такой механизм использует ASProtect.
Поскольку «плавающий» ключ, сколь успешно бы он не был замаскирован, все-таки отличается от окружающих его ключей, определив признаки, которые отличают «плавающий» счетчик от обычных ключей реестра, возможно создать программу, которая бы автоматически выявляла подозрительные ключи, что подтверждается наличием как минимум трех независимо разработанных утилит, способных выявлять и удалять ключи, созданные ASprotect’ом. В любом случае, изменения в реестре, возникшие после первого запуска программы, нетрудно обнаружить при помощи программ мониторинга реестра. Важно отметить, что программы, в которых установлено ограничение на число пробных запусков, при первом запуске всегда создают либо модифицируют один из ключей реестра. Это напрямую следует из логики их работы: для работы такой защиты необходим счетчик запусков, а создать такой счетчик можно только во время инсталляции либо при первом запуске. Разумеется, программа, содержащая ограничение на число запусков, должна увеличивать либо уменьшать значение этого счетчика после каждого запуска. Кроме того, запись в реестр при каждом запуске возможна также и при ограничении по времени использования. Такая запись играет роль дополнительной защиты от изменения системного времени: если дата текущего запуска меньше даты предыдущего или отличается от него на считанные секунды, программа может предположить, что пользователь пытается использовать программу сверх установленного срока. Обнаружить такую защиту сравнительно легко – многократную модификацию одного и того же ключа скрыть практически невозможно, не помогают даже такие приемы, как дублирование счетчика и «плавающий» счетчик. Сбрасывается такой счетчик тоже без особых сложностей – достаточно один раз запомнить состояние соответствующих ключей реестра и затем восстанавливать его перед каждым запуском. Интересно отметить, что даже столь простую идею, как отслеживание времени, некоторые разработчики ухитряются реализовать некорректно (по крайней мере, под ОС Windows такое встречается не так уж редко).Windows позволяет оперировать двумя типами времени: системным (оно же «всемирное», UTC) и местным (Local).
Причем во многих странах местное время может быть зимним и летним. И если защита ориентируется на местное время, в день перевода часов пользователя может ожидать сюрприз: после автоматического перевода часов, выполняемого ОС, программа может просто перестать работать. Для этого нужно лишь, чтобы пользователь запустил программу непосредственно перед переходом с летнего времени на зимнее, и потом – еще один раз в течение ближайшего часа. По этому нехитрому признаку можно в известной степени судить о квалификации разработчика защиты. Наконец, последним важным моментом в жизни программы является ее первый запуск после истечения срока триального использования. Поскольку большинство лицензионных соглашений запрещают пробное использование программы сверх установленного «испытательного срока», программа может «помочь» пользователю соблюсти условия лицензионного соглашения, сделав в реестре метку, запрещающую дальнейшую работу программы. Эта метка может быть как новым ключом в глубинах реестра (или файлом в какой-нибудь системной директории), так и особым значением уже существующего ключа. Проверка значения такого ключа также может быть встроена в инсталлятор программы, чтобы пользователь не мог просто деинсталлировать и переустановить программу. Какие выводы следуют из всего, что было сказано выше? Во-первых, то, что триальные механизмы в программах могут быть довольно разнообразны по исполнению (и, кроме того, иногда дублируются разработчиком для повышения надежности), а их поиск и удаление требует аккуратности и терпения. Несмотря на то, что создание снимков системы требует некоторого времени, пренебрежительное отношение к этим действиям может создать Вам значительные трудности в будущем. Во-вторых, существуют критические точки, при прохождении которых рекомендуется отслеживать состояние системы. В-третьих, можно составить универсальный план действий, который с очень высокой вероятностью позволил бы выявить изменения в системе, вносимые триальными механизмами, проанализировать их и выработать способы противодействия защите.
Лично я чаще всего использую следующую последовательность действий, придерживаться которой рекомендую и Вам:
Если программа успешно «проглатывает» любое изменение даты, высока вероятность того, что защита реализована максимально простым способом, и обойти ее будет нетрудно. Разумеется, все эти действия должны сопровождаться созданием снимков системы: если программа обнаружит Ваши манипуляции с системным временем, она может отреагировать на это созданием «метки», свидетельствующей о попытке обойти защиту.
Переменные и константы.
Одна из первых задач, с которыми сталкивается крэкер - поиск в тексте программы констант или обращений к переменным, отвечающих за реализацию ограничений в незарегистрированных и демонстрационных программах. Нередко также возникает необходимость извлечь из программы какие-либо данные, либо заменить их своими собственными. Поскольку в данной главе пойдет речь не только об изучении, но и об изменении программ, сразу же ознакомлю Вас с одним из важнейших, с моей точки зрения, принципов, которым нужно руководствоваться, если приходится прибегать к модификации данных или исполняемого кода. Это принцип минимального вмешательства, который можно сформулировать так: Чем меньше изменений внесено в логику программы, тем менее вероятно, что эти изменения приведут к некорректной работе программы. Приблизительный рейтинг изменений по степени их влияния на логику программы (от минимального к максимальному) выглядит следующим образом: - Модификация константы - Изменение значения предварительно инициализированной переменной - Изменение условия перехода на противоположное - Удаление линейной, то есть не содержащей ветвлений и вызовов нетривиальных подпрограмм, последовательности команд - Дописывание в программу собственного кода; изменение значения, возвращаемого функцией - Модификация внедренного в программу ресурса, важного для логики программы (т.е. такого, изменение которого меняет поведение программы) - Удаление из программы логического блока (например, вызова нетривиальной функции); удаление внедренного в программу ресурса Этот принцип, как любое другое широкое обобщение, требует достаточно осторожного отношения и в некоторых случаях может повести Вас по неоптимальному пути. Но на практике принцип минимального вмешательства обычно работает вполне успешно. Да и с точки зрения простого здравого смысла очевидно, что найти и исправить условие, которое проверяет зарегистрированность программы – прием куда более безобидный и надежный, чем вырывание с корнем nag-screen - ов и триальных ограничений при помощи глубокого патчинга. Как я уже говорил, многие незарегистрированные программы содержат количественные ограничения.Даже число жизней в компьютерной игре можно отнести к ограничениям количественного характера (как ни странно, крэкинг иногда применяется в качестве средства для беспроблемного прохождения компьютерных игр; «вечная жизнь» и «бесконечное оружие» - это тоже один из аспектов крэкинга, возможно даже самый старый). В предыдущей главе мы уже рассматривали некоторые аспекты реализации таких ограничений и способы их обхода. Но, к сожалению, все чаще встречаются случаи, когда манипуляции с системным временем, файлами и реестром не помогают: это всевозможные ограничения на продолжительность одного сеанса работы с программой, на максимальное количество создаваемых или обрабатываемых документов, на число записей в базе данных и т.п. Так что пришло время поговорить о том, каким образом хранится информация внутри программ и что с этой информацией можно сделать. Наверное, наиболее распространенным типом данных, используемым в программах, в настоящее время являются целые числа. Абсолютное большинство существующих процессоров, в том числе и наши любимые x86, изначально ориентировались на работу с целыми числами определенной разрядности (в настоящее время на платформе x86 наиболее актуальны 32-битные целые со знаком или без). Именно целые числа являются наиболее естественных форматом для хранения всевозможных счетчиков и контрольных сумм. Однако возможны и более неординарные применения: в тридцати двух битах можно успешно хранить числа с фиксированной точкой и даже логические значения. Когда я переходил от программирования под ДОС к программированию под Win32, меня сильно удивляла расточительность фирмы Microsoft, отводившей под простую булевскую переменную целых 4 байта, и только более глубокое изучение архитектуры 32-разрядных процессоров и ассемблера расставило все по своим местам. Что Вы будете делать, если Вам потребуется найти в программе сравнение чего-либо (например, регистра или содержимого ячейки памяти) с определенной целочисленной константой? Для некоторых целых чисел достаточно обычного поиска блока двоичных данных в файле.
Однако двоичный поиск далеко не со всеми числами работает одинаково хорошо: если Вы ищете число 3B9ACA00h, вероятность ложного срабатывания будет весьма небольшой, но вот если Вы попытаетесь найти в исполняемом файле число 10 или 15, то, скорее всего, просто устанете нажимать на кнопку «Найти далее». Если вспомнить, что числа 10 и 15 могут храниться не только как 32-битные, но и как одно- и двухбайтные, становится ясно, что двоичный поиск небольших чисел в исполняемых файлах – далеко не самая лучшая идея. Кроме того, при таком способе поиска никак не учитывается структура исполняемого файла программы, поскольку Вы ищете нужную Вам константу не только в коде программы, но и в PE-заголовке, секции данных, секции ресурсов и прочих областях, имеющих к коду программы самое отдаленное отношение. Хотя в принципе эта проблема, конечно, решаема: нужно лишь ограничить область поиска секциями, содержащими данные и код. Однако есть и другой, более эффективный метод поиска в программе известных заранее значений. Как ни странно, но в реальных программах широко используется лишь сравнительно небольшой набор целочисленных констант: это, прежде всего, небольшие положительные числа от 0 до 7 (а также небольшие отрицательные от -3 до -1) и степени двойки: 8, 16, 32 и т.д. Другие константы в программах встречаются значительно реже. Попробуйте сами провести эксперимент – дизассемблируйте какую-нибудь достаточно большую программу и найти в ней какое-нибудь сравнительно небольшое число, например, 32h, которое в этой программе заведомо имеется. Для этого эксперимента я написал простейшую программку на Delphi 7, вся функциональность которой концентрировался в следующем коде, имитирующем простейшее ограничение на число строк в документе: procedure TForm1.Button1Click(Sender: TObject); begin if Memo1.Lines.Count>50 then begin Application.MessageBox('More than 50 items not available','Demo version'); Close; end else Memo1.Lines.Add(Edit1.Text); end; В результате компиляции этот весьма нехитрый текст превратился в исполняемый файл размером более 350 килобайт (я намеренно создал проект с визуальными компонентами, а также использовал режим компиляции без runtime packages, чтобы мой собственный код составлял в исполняемом файле очень малую долю по сравнению с библиотеками Delphi).
Затем я дизассемблировал откомпилированную программу при помощи W32Dasm и получил листинг текст длиной более 180000 строк. Казалось бы, обнаружить область, где происходит сравнение с числом 50 в этом листинге ничуть не проще, чем найти иголку в стоге сена. Но я воспользовался функцией поиска в тексте строки, в качестве параметра поиска указав 00000032 (так в W32Dasm отображается число 50; заодно это позволило отсеять команды вроде mov eax,[ebx+32h], обычно использующиеся для доступа к элементам массивов и полям структур). Реальность превзошла самые смелые ожидания: четырехбайтное число 32h встретилось в листинге всего два (!!!) раза: :004478C6 6A32 push 00000032 и :004505BB 83F832 cmp eax, 00000032 Чтобы догадаться, что нужное нам сравнение происходит во второй строке, достаточно самых минимальных познаний в ассемблере. Из этого следует вывод: поиск нужной константы в дизассемблированной программе вполне реален, даже несмотря на огромный объем листинга. Далее: Вам наверняка интересен не сам факт наличия константы где-то в недрах кода, а то, в каком контексте эта постоянная используется. Иными словами, если Вы знаете, что программа сравнивает число записей в базе данных с некоторым значением (в нашем случае - 32h), то среди всех строк, в которых присутствует эта константа, в первую очередь следует рассматривать команды сравнения (cmp) и вычисления разности (sub иsbc), и лишь затем – все прочие, менее очевидные способы сравнения вроде mov ebx,50cmp eax, ebx или push 50push eaxcallCompare2dwords Ну и, раз уж речь зашла о сравнениях, нельзя не упомянуть об альтернативных вариантах реализации этой, казалось бы, нехитрой операции. Поразмыслим над приведенным выше примером сравнения содержимого регистраeax с числом 50. В самом деле, условияeax>50 и eax>=51 в приложении к целым числам имеют один и тот же смысл, а код cmp eax,50 jg my_label работает совершенно аналогично коду cmp eax,51 jge my_label Если необходимо выяснить, больше ли содержимое регистра EAX, чем 31, или нет, то проверка может выглядеть даже так: and eax, 0FFFFFFE0h jnz my_label Также при написании программ нередко возникает необходимость сравнить переменную со значением и обработать три возможных ситуации: «переменная равна числу», «переменная больше, чем число» и «переменная меньше, чем число».
В исходных текстах программ на языках высокого уровня это обычно реализуется трехэтажной конструкцией вида if my_var=[число] then <переменная_равна_числу> else if my_var [число] then <переменная_больше_числа> else <переменная_меньше_числа> На ассемблере эта конструкция тоже реализуется в три строчки, однако операция сравнения здесь требуется всего одна: cmp my_var,[число] jz is_equal jg is_more_than Всеми этими примерами я хотел продемонстрировать ту идею, что практически любая операция с численными данными быть реализована несколькими разными способами, и когда Вы будете искать константы в дизассемблированном тексте, этот факт тоже надо учитывать. Если рассматривать области возможного практического применения вышеописанного приема, то лучше всего поиск известной константы в дизассемблированном тексте работает на триальных ограничениях типа «не допускается создание большеN элементов в базе данных». Как правило,N больше 7 и является целым числом, что облегчает поиск нужной константы. Исходя из принципа минимального вмешательства, для обезвреживания таких ограничений я предпочитаю исправлять не команды сравнения, а сами константы. Действительно, если программа для проектирования интерьера комнаты не способна работать более, чем с 20 объектами, для практического применения она вряд ли будет пригодна. Но вот та же программа, где максимальное количество обрабатываемых объектов увеличено до двух с хвостиком миллиардов наверняка удовлетворит даже самого взыскательного пользователя. Одним из наиболее частых вопросов, возникающих у начинающего крэкера, звучит так: «Программа работает 30 дней, но я так и не нашел в листинге сравнения с числом 30. Что делать?». Один из факторов я уже описал выше – там могло быть сравнение не с числом 30, а с числом 31. Однако этим список возможных причин неудачи не исчерпывается. Как мы все знаем, день состоит из 24 часов, каждый из которых состоит из 60 минут, каждая из которых состоит из 60 секунд.
Более того, продолжительность секунд также может измеряться во всевозможных «условных единицах», например, в миллисекундах. Тысячные доли секунд, в частности, используются в таких функциях WinAPI, как SetTimer (таймеры Windows часто используются для установки ограничений на продолжительность одного сеанса работы с программой) илиSleep. А вот в функциях, возвращающих время в виде структуры типа FILETIME, используются уже другие «условные единицы», равные ста наносекундам. Так что пресловутые 30 дней – это не только 30 дней, но еще и 720 часов, 43200 минут, 2592000 секунд, ну и так далее. И каждое из этих значений может быть использовано в программе как один из аргументов операции сравнения. Надо отметить, что в «условных единицах» может быть представлено не только время, но и многие другие величины: масса, географические координаты, денежные единицы и т.д. Раз уж речь зашла о представлении временных отрезков внутри программ, уместно будет рассказать и о тонкостях использования таймеров. Наверняка Вы встречали программы, в которых после запуска окно с предложением о регистрации висит на экране в течение некоторого времени (иногда еще в этом окне идет обратный отсчет секунд), и при этом его невозможно закрыть. Подобные «спецэффекты» по усмотрению разработчика могут сопровождать вызов каких-либо функций программы, сохранение файлов или завершение приложения – это не суть важно. Важно другое: все эти временные задержки так или иначе используют средства измерения времени. В ОС Windows существует два наиболее популярных способа отсчета отрезков времени: использование функций задержки (в частности, функции Sleep) и использование всевозможных таймеров. Вообще в Windows существует несколько разновидностей таймеров – кроме обычного таймера, создаваемого функцией SetTimer, существует еще высокоточный мультимедийный таймер и специфические таймерные функции DirectX. Эти таймеры срабатывают с некоторой заданной частотой, вызывая функцию-обработчик (она же callback-функция), внутри которой и выполняются необходимые действия, например, тот же обратный отсчет секунд до исчезновения окна с предложением зарегистрироваться.
Периодичность срабатывания таймера почти всегда является константой, однако взаимосвязь между тем, что происходит внутри программы и тем, что Вы можете видеть на экране, не всегда очевидна. Чтобы пояснить эту мысль и заодно продемонстрировать на практике, как можно обращаться с таймерами, приведу несколько примеров. Первый пример – простейший: программа, которая при запуске в течение пяти секунд показывала баннер, при этом поверх баннера выводился обратный счетчик секунд. Регистрация в программе не предусматривалась. Дизассемблирование показало, что таймер срабатывает каждые 1000 миллисекунд, при каждом вызове callback-функции значение переменной, изначально равной пяти, уменьшалось на единицу, и результат проверялся на равенство с нулем. В той конкретной программе баннер можно было просто «выломать», убрав функцию создания и отображения рекламного окна, но в общем случае это решение было бы не лучшим (вспомните принцип минимального вмешательства). И вот почему: на последнее срабатывание таймера могло быть «подвешено» не только закрытие окна с баннером, но и инициализация каких-либо объектов внутри программы или другие критичные действия, без которых программа могла бы работать некорректно. Так что немного усложним задачу – будем считать, что полностью убирать вызов окна с рекламой нельзя. Первое, что нам приходит в голову – уменьшить число секунд, в течение которых показывается баннер. Сказано – сделано, цифру 5 исправляем на единицу. Однако баннер все равно висит целую секунду – ведь первое срабатывание таймера наступает только через секунду после его создания. Теперь уменьшим период таймера до нуля (хотя лучше все-таки до одной миллисекунды, «таймер с периодом 0 миллисекунд», согласитесь, штука довольно странная). В результате мы получили баннер, появляющийся при запуске программы лишь на мгновение и не заставляющий тратить целых пять секунд на праздное разглядывание рекламных лозунгов. В качестве второго примера я возьму одну из старых версий TVTools.
В справке к программе было четко указано, что незарегистрированная версия работает только 10 минут; дизассемблирование и анализ листинга выявили, что программа создает два таймера с периодами 60 секунд (что навело меня на мысли о защитном назначении этого таймера) и 2 секунды. Без особых сложностей обезвредив первый таймер, я запустил программу и обнаружил, что она все равно больше 10 минут не работала. Тогда я более пристально изучил callback-функцию второго таймера, и наткнулся в ней на такой код: inc dword_40D5A7
cmp dword_40D5A7, 136h
jbe short loc_405CAA
Нетрудно догадаться, что это увеличение некоего счетчика, который затем сравнивается с числом 310. Поскольку период таймера – 2 секунды, а 310*2=620 (т.е. чуть больше 10 минут), логично было предположить, что это и есть второй уровень защиты, дублировавший первый. Очевидно, что если бы я принял на веру, что программа перестает работать ровно через 10 минут (а не через 10 минут 20 секунд, как это оказалось в действительности) и стал бы искать сравнение с числом 300, я бы не смог обнаружить таким способом вторую проверку времени работы программы. Этот пример демонстрирует один из неочевидных приемов, который может быть использован для реализации такой, казалось бы, простой операции, как отсчет 10-минутного интервала. Также из этих примеров следует и другой, не менее важный вывод: далеко не всегда следует искать известную константу, чтобы найти код, в которой она используется. Иногда следует поступать прямо противоположным образом – сначала искать код, выполняющий нужные действия, и лишь затем выяснять, какая константа внутри этого кода ответственна за интересующие нас действия. Поиск констант с плавающей точкой – занятие с одной стороны более сложное, чем поиск целочисленной константы, но с другой – куда более простое. В чем сложность и в чем простота этого занятия? По традиции начнем с плохого. Во-первых, формат представления чисел с плавающей точкой весьма нетривиален, и Вы вряд ли сможете в уме привести шестнадцатеричный дамп такого числа в «человеческий» вид (возьмите документацию по процессорам Intel и попробуйте перевести число 1.23 в машинное представление, а затем проделать обратную операцию – Вы сами убедитесь, насколько сложна эта задача).
Более того, даже целые числа в представлении с плавающей точкой выглядят весьма неординарно: к примеру, дамп самого что ни на есть обычного числа 123, приведенного к типу Double, выглядит как 00 00 00 00 00 C0 5E 40. Если Вы способны с первого взгляда отличить число с плавающей точкой от кода программы или каких-либо иных данных и оценить величину этого числа – я рад за Вас, но большинство людей, к сожалению, такими способностями не обладают. Во-вторых, при работе с дробными числами нередко возникают проблемы, связанные с машинным округлением и потерей точности. Самым ярким примером, наверное, может служить особенность математических программ ПЗУ некоторых моделейSpectrum: с точки зрения такого Спектрума выражение 1/2=0.5 было ложным. Это, конечно, было давно, но не следует считать, что современные компьютеры полностью свободны от этой проблемы. И вот практическое тому подтверждение. Откомпилируйте под Delphi следующий код: i:=sin(1); i:=arcsin(i) и посмотрите, как будет меняться результат при изменении типа переменнойI от Single до Extended. Например, если I имеет тип single, в результате вычислений получим, что arcsin(sin(1))=0,999999940395355. Такие «спецэффекты» – следствие все той же потери точности в процессе вычислений. В-третьих, округлением чисел процессор может заниматься не только по собственному желанию, но и по велению программы. К примеру, в большинстве бухгалтерских программ всевозможные ставки налогов выводятся с точностью до копеек. Однако из того, что Вы видите на экране число 10.26, совершенно не следует, что результат расчетов представлен в памяти ЭВМ именно как 10.26. Реальное значение соответствующей переменной может быть равно 10.258 или 10.26167, которое и участвует в реальных расчетах, и лишь при выводе на экран для удобства пользователя было произведено округление до двух знаков после запятой. Я не случайно столько места уделил округлению и точности представления чисел – именно эти особенности чисел с плавающей точкой в наибольшей мере затрудняют поиск нужных значений в памяти программы.
Программисты знают, что при работе с действительными числами для проверки условия равенства некоторой вычисляемой величины другой величине не рекомендуется использовать сравнения видаf(a)=b. Причина этого лежит все в той же проблеме округления и потери точности расчетах – вспомните вышеприведенные примеры со Спектрумом или арксинусом синуса единицы. Вместо простой проверки равенства обычно используется условие «значения считаются равными, если абсолютная величина разности между ними не превышает некоторой величины»: abs(f(a)-b)<=delta, где delta – максимально допустимая величина разности, после которой числа не считаются равными. Поэтому если Вы хотите найти в памяти некоторое число с плавающей точкой F, Вы в действительности должны искать все числа из промежутка [F-delta; F+delta], причем определить значение delta чаще всего можно лишь опытным путем. Это утверждение распространяется и на тот случай, когда Вы знаете округленное значение переменной, но в этом случае величина delta будет зависеть от того, до скольких знаков округлено значение переменной. Так, если число округлено до сотых, нетрудно догадаться, что delta=0.005. Вот тут-то Вы и столкнетесь с чисто практической проблемой неприспособленности существующих отладчиков к поиску чисел с плавающей точкой. Сама по себе функция поиска действительных чисел в адресном пространстве программы в отладчиках встречается редко, а уж отладчиков, поддерживающих поиск чисел из заданного промежутка, я вообще не встречал. Поэтому Вам, вероятно, придется для этой цели написать собственный инструмент либо искать способ получить нужную информацию каким-то другим путем. И, наконец, нельзя забывать, что кроме стандартных для платформы x86 типов Single, Double и Extended (32-, 64- и 80-битных соответственно) существует еще несколько довольно экзотических, но все еще используемых форматов. Это, к примеру,Currency (64-битные, с фиксированным положением десятичной точки) или 48-битные паскалевскиеReal. Возможно также использование «самодельных» форматов; особенно часто встречаются числа с фиксированным положением десятичной точки (обычно такое делается для повышения скорости работы программы и применяется в основном в процедурах кодирования/декодирования аудио- и видеоинформации).
Знать о таких вещах совсем не лишне, хотя, конечно, вероятность столкнуться с такими числами в современных программах довольно низка. Теперь немного поговорим о том хорошем, что есть в числах с плавающей точкой. Как известно, изначально в процессорах x86 встроенных аппаратных и программных средств для обработки чисел с плавающей точкой не предусматривалось. Низкая скорость расчетов, в которых использовались действительные числа, вызвала к жизни математические сопроцессоры, как традиционные x87, так и весьма экзотические девайсы Weitek. Победившая линейка сопроцессоров x87 (они с некоторых пор стали интегрироваться в ядро процессора и потому перестали существовать как отдельные устройства) имела следующую особенность: новые «математические» команды активно использовали для обмена информацией оперативную память. Посмотрите, к примеру, на важнейшие команды сопроцессора fst и fld – в качестве параметра этих команд могут выступать указатели на области памяти, которые предполагается использовать для чтения/записи данных. Более того, использование указателей в качестве одного из параметров характерно и для многих других команд сопроцессора. Поэтому ищите ссылки, используемые командами сопроцессора в качестве параметров – и Вы легко доберетесь до данных, на которые эти ссылки указывают. Из этого следует вывод: хороший дизассемблер или отладчик способен «догадаться», что по адресу, указанному в аргументах этих команд, находится число с плавающей точкой и отобразить это число. Если же Ваш дизассемблер/отладчик об этом не догадывается – Вам придется вручную (точнее говоря, при помощи соответствующих программ) вычислить значение, которое находится по этому адресу. И пока Вы будете копировать байтики из одной программы в другую, у Вас будет достаточно времени подумать об обновлении инструментария. Но и здесь не обошлось без ложки дегтя – компиляторы фирмыBorland, видимо, ради особой оригинальности, для загрузки констант в стек сопроцессора могут воспользоваться комбинациями вроде mov [i],$9999999a mov [i+$4],$c1999999 mov word ptr [i+$8],$4002 fld tbyte ptr [i] Хотя, казалось бы, ничто не мешало положить несчастное число в секцию инициализированных данных… Тут уж не до «умного» поиска – разобраться бы, чего и куда вообще загружается.
Хотя, при желании и умении обращаться с регулярными выражениями ( или умении программировать) можно искать даже в таком коде. Другим свойством действительных чисел, облегчающим автоматический поиск известной величины, является само их внутреннее устройство. Достаточно большая длина этих чисел (32 бита, а чаше всего – 64 или 80) и сложный формат хранения позволяет искать числа с плавающей точкой в любых файлах, в том числе и в исполняемых файлах программ, непосредственно в двоичном виде, причем вероятность ложного срабатывания будет незначительной. Даже существование нескольких различных форматов представления действительных чисел не представляет серьезного препятствия – соответствующая программа очень проста и пишется за считанные минуты. Народная мудрость гласит: «лучше один раз увидеть, чем сто раз услышать», поэтому в качестве практики я рекомендую Вам самим написать и отладить такую программу – это не только усовершенствует Ваши навыки в программировании, но и позволит поближе познакомиться с миром действительных чисел. Затем, если захотите, Вы сможете доработать эту программу таким образом, чтобы она могла осуществлять нечеткий поиск в файле, о котором я говорил выше, то есть поиск всех значений, подходящих под заданный пользователем интервал. И, наконец, рассмотрим третий из наиболее часто встречающихся простых типов данных: текстовые данные. Вообще, методы представления текстовых строк и массивов в коде программ имеют давние и богатые традиции. Наиболее старым способом является выделение под строку участка фиксированного размера, причем неиспользуемая часть блока заполняется «нулевыми» символами. В чистом виде этот прием уже давно не встречается (из примеров вспоминаются разве что старые реализации классического Паскаля), но нечто подобное иногда используется в программах на Си для хранения массивов строк – под хранение каждого из элементов массива отводится блок фиксированного размера, хотя сами элементы, по сути, являются ASCIIZ-строками. Хранение строк в блоках фиксированного размера имело два принципиальных недостатка: неэффективное расходование памяти при хранении большого числа строк различной длины и жесткое ограничение на максимальную длину строки.
Всех этих недостатков были лишены строки с завершающим символом. Идея была проста – выбирается какой-либо малоиспользуемый символ, который интерпретируется программой как признак конца строки. В языке Си таким символом стал символ с кодом 0 (а строки, оканчивающиеся нулем, окрестили ASCIIZ-строками); некоторые системные функции MS-DOS в качестве завершающего символа использовали символ “$”. Несмотря на ряд недостатков, строки с завершающим символом претерпели ряд усовершенствований и используются до сих пор. С началом активного использования UNICODE появилась модификация строк с завершающим символом и для этой кодировки. Зная образ мышления программистов на Си, нетрудно догадаться, что в качестве завершающего символа была использована пара нулевых байтов: (0,0). Нужно отметить, что если возникает необходимость укоротить такую строку в тексте программы на несколько символов, то обычно для этого достаточно всего лишь вписать в нужную позицию завершающий символ. То есть, если у Вас есть программа, написанная на C/С++, в заголовке окна которой написано что-то вроде «CoolProgram -Unregistered», и Вы не хотите видеть напоминание о том, что она «Unregistered», просто замените в файле программы пробел после слова Program на символ с кодом 0. После этого слово «Unregistered» Вы почти наверняка больше не увидите. Этим же способом ненужную строку можно вообще превратить в пустую, просто поставив в ее начало завершающий символ! Описанная техника укорачивания и «обнуления» строк пригодна не только для того, чтобы убирать неэстетичные надписи в заголовках программ, в действительности ее возможности гораздо шире. Приведу пару примеров из собственной практики. Один раз мне в руки попалась некая программа, которая очень любила при печати документов в заголовок вставлять надпись «Thisreportcreatedbydemoversion …» шрифтом аршинного размера. Разумеется, мне это не понравилось и при помощи нехитрых манипуляций в шестнадцатеричном редакторе я «обнулил» строку с надписью, оскорблявшей мои эстетические чувства.
В другом случае я подобным же образом расправился с одной программой-генератором справок, которая считала, что незарегистрированность – это повод вставлять рекламный текст в каждую статью справочной системы. Небольшой memorypatch, исправлявший в программе «на лету» несколько байт, смог убедить капризную программу в ее принципиальной неправоте. Более эффективными по сравнению с ASCIIZ-строками являются строки с указанием длины. Такие строки позволяют использовать в тексте все 256 ASCII-символов, хранить не только текстовые, но и любые другие двоичные данные, а также применять по отношению к этим данным строковые функции. Кроме того, вычисление длины строки требует лишь одной операции чтения данных по ссылке, в отличие от ASCIIZ-строк, где для определения длины необходимо последовательно сканировать все символы строки до тех пор, пока не встретится завершающий символ. Как такового, стандарта на строки с указанием длины не существует – можно лишь говорить о конкретных реализациях таких строк в различных компиляторах и библиотеках. В частности, в коде программ на Delphi 7 строковые константы хранятся следующим образом:
Прежде всего, не следует полагаться на «интеллектуальность» дизассемблера: поиск текстовых строк при дизассемблировании обычно основан на анализе ссылок, встречающихся в коде программы. Поэтому, если в коде программы нет прямой ссылки на строку, дизассемблер эту строку может просто «не увидеть». Чтобы убедиться в этом, рассмотрим несложный пример: .data line1 db "Line 1",0 line2 db "Line 2",0 line3 db "Line 3",0 LineArr dd OFFSET line1, OFFSET line2, OFFSET line3 .code … GetMsgAddr proc MessageIndex:DWORD mov ebx,MessageIndex mov eax, OFFSET LineArr mov eax,[eax+ebx*4] retGetMsgAddrendp … Этот код представляет собой максимально упрощенную реализацию списка сообщений и функции, получающей адрес текстовой строки по номеру сообщения. Откомпилировав этот пример, загрузим его вW32Dasm и посмотрим, что получится. Получилось следующее: дизассемблер успешно распознал строку «Line 1», но строки «Line 2» и «Line 3» не обнаружил. А вот IDA успешно распознал все три строки, и создал для них именованные метки. Впрочем, и IDA при большом желании можно обмануть: достаточно лишь вписать перед текстом самой строки ее длину в байтах (именно так хранит строки Delphi). После этого IDA хотя и обнаруживает сам факт наличия текстовых строк в программе (в окне Strings эти строки видны), но в дизассемблированном тексте программы эти строки выглядят как последовательность db… , которые нужно приводить в желаемый вид вручную. Кстати, W32Dasm после этой модификации не увидит вообще ни одной строки. Если же Вам и этого мало, вместо «Line 1» напишите «Строка 1» - все тексты на русском языке знаменитый дизассемблер гордо проигнорирует. И это только начало. А ведь текстовые строки могут находиться не только в сегменте кода/инициализированных данных, но и в секции ресурсов программы… Здесь могут помочь специализированные программы, сканирующие указанный файл и вычленяющие из него все текстовые строки (или то, что похоже на текстовые строки).
Кроме того Вам потребуются смещения этих строк от начала файла, поэтому Ваш инструмент должен предоставлять и такой сервис. Однако использование таких программ (и самостоятельное их написание) осложняется двумя факторами: разнообразием существующих кодировок текста и существованием национальных символов в некоторых языках (классический strings.exe и многие другие подобные программы «не понимают» русскую секцию UNICODE). Те же проблемы с UNICODE и национальными кодировками характерны и для программного обеспечения, осуществляющего поиск в текстовых файлах. К тому же русские тексты в UNICODE совершенно нечитабельны в шестнадцатеричных редакторах и просмотрщиках. Все это необходимо учитывать при выборе инструментов поиска текстовых строк, а выбранный инструмент перед использованием желательно проверить на подходящем «пробном камне». Напоследок расскажу про весьма простой, но весьма эффективный в некоторых случаях способ поиска численных переменных в работающей программе. Этот способ основан на многократном сканировании адресного пространства программы, отслеживании и анализе всех изменений в этом пространстве. Лучше всего этот прием работает на программах, в которых установлено ограничение на количество тех или иных действий, вроде ограничения на число записей, добавляемых в документ. И используется для этого совсем не крэкерский инструментарий. Вы, наверное, знакомы с программами типа GameWizard или ArtMoney, которые позволяют искать в работающей компьютерной игре количество денег или оставшихся жизней. Для тех, кто не сталкивался с такими программами, вкратце опишу алгоритм их работы:
И потому, воспользовавшись соответствующей программой (например, все той же ArtMoney), можно определить адреса всех переменных, которые могут хранить счетчик добавленных в документ записей. Дальнейшие действия зависят исключительно от Вашего желания – можно поставить аппаратную точку останова на чтение из этой переменной и попытаться добраться до команды сравнения существующего числа записей с максимальным. Можно погрузиться в изучение дизассемблерного листинга в поисках команды увеличения переменной и сделать так, чтобы значение счетчика не увеличивалось. Можно даже попробовать модифицировать счетчик из ArtMoney, посмотреть, что из этого получится, и если из этого получится что-то хорошее – написатьmemorypatch, каждые 100 миллисекунд обнуляющий счетчик. На этом мой рассказ о простых типах данных в основном закончен, а информацию о методах хранения составных типов, таких как структуры и массивы, о том, где обычно в программах лежат константы, а также об особой роли указателей Вы найдете в следующей главе. [C] CyberManiac Содержание Далее
Структуры, массивы, ссылки.
Читая предыдущую главу, Вы, наверное, были неприятно удивлены тем, с какими сложностями можно столкнуться, решая такую нехитрую задачу, как поиск в программе нужной константы. И, возможно, подумали, что если простые числовые и символьные константы способны создать столько проблем, то каково же будет разбираться в составных типах данных! Поспешу Вас успокоить – разобраться в структурированных данных обычно не слишком сложно именно в силу их регулярной структуры. К тому же, в этой главе мы не будем рассматривать такие достаточно сложные вещи, как тонкости представления текста в различных кодировках, и размышлять над глобальными проблемами машинного округления – это уже пройденный этап, и, я думаю, Вы сможете эффективно применить эти знания без дополнительных подсказок. Впрочем, простота теоретической части обсуждаемой нами темы компенсируется сложностями при практическом применении предлагаемых Вам знаний. Сразу отмечу, что эта глава посвящена не столько непосредственному взлому программ, сколько техникам «вытаскивания» структурированной информации, упрятанной в недрах исполняемых файлов. Впрочем, техники, описанные в этой главе, с некоторыми модификациями могут быть применены и к другим типам файлов, хранящих структурированные данные. Это может быть полезно, к примеру, для тех, кому потребуется извлечь из программы какие-либо данные или «расколоть» базу данных заранее неизвестного формата. Рассказывая о нецелочисленных константах, я упоминал о том, что обращения к таким константам производятся по указателю, то есть в команде, загружающей действительное число в регистр сопроцессора, в явном виде присутствует адрес загружаемого числа. Однако доступ к данным по указателю на них характерен не только чисел с плавающей точкой (на некоторых платформах числа с плавающей точкой отлично умещаются в регистрах общего назначения), но и для строк, структур и массивов. Теоретически, небольшую структуру или короткий массив еще можно было бы попытаться разместить в регистрах общего назначения (собственно, при предельной оптимизации кода иногда так и поступают), но большинство реальных данных требуют для своего хранения гораздо больше места, чем могут предоставить регистры процессора.Наиболее простым и быстрым способом работы с такими объемными данными является хранение этих данных в оперативной памяти и обращение к ним через посредство указателей. Теперь перейдем к практическим аспектам использования указателей в программах. Здесь работают три очень простых правила: · к любой константе длиннее 4 байт обращаются по ее адресу · к любой константе составного или строкового типа обращаются по ее адресу · в коде любой программы адрес начала статической переменной (т.е. переменной, память под которую выделяется в момент запуска программы) явно указан как минимум один раз независимо от типа этой переменной Это, конечно, очень широкое обобщение (и не всегда верное – достаточно вспомнить пример с загрузкой 10-байтной вещественной константы в Delphi из предыдущей главы), но, в общем, современные компиляторы действительно используют ссылки весьма широко. И если в программе по адресу X находится строковая константа ‘My text’, то почти наверняка где-то в коде программы найдется команда push X, mov eax,X или что-либо подобное. Именно на этом факте наличия «указателей на все, к чему можно обратиться по указателю», а также на отсутствии путаницы с сегментами и смещениями и основана «разумность» дизассемблеров под Win32, поначалу сильно удивляющая тех, кто успел привыкнуть к маловразумительным листингам дизассемблированных 16-разрядных программ для MS DOS. Хотя при наличии очень большого желания (и еще большего терпения, чтобы это отладить) все-таки можно обращаться к данным, не используя явные ссылки на эти данные. Не верите? Тогда попробуйте разобраться в следующем коде для платформы Win32 и найти в нем хоть одну ссылку на выводимые в MessageBox’е строки: push 0 call $+1Ah db 'Code sample',0,'Any text',0 mov eax,[esp] add eax,0Ch push eax push 0 call MessageBoxA Такие могучие коды начисто «срывают башню» даже IDA Pro, который оказывается совершенно неспособен более или менее логично дизассемблировать это нечто.
Вот уж воистину «горе от ума» - древний, по нынешним меркам, W32DAsm дизассемблировал этот код гораздо адекватнее. Разумеется, знаменитому дизассемблеру при желании можно (и даже не очень сложно) объяснить, где в действительности находятся данные, а где – код, который к этим данным обращается, но чтобы это сделать придется сначала разобраться в приведенном коде самому. Понятно, что, написать целую программу таким «высоким штилем» вряд ли у кого-то получится (да и языки высокого уровня мало приспособлены к подобным экзерсисам), но определение адреса блока данных при помощи пары команд call $+5 pop eax в защитных процедурах (в частности – в процедурах расшифровки кусков кода) встречается довольно часто. Другим важным моментом, который необходимо помнить при «расшифровке» структур и массивов, хранимых в коде программ, является то, что популярные компиляторы не поддерживают использование структур переменного размера. То есть длина структуры жестко фиксирована и постоянна для всех структур одного и того же типа: если переменная A типа MY_TYPE занимает 100 байт, то переменная B того же типа также будет занимать 100 байт независимо от того, какие данные в ней хранятся. Возникает вполне естественный вопрос: а как же тогда хранятся строки или динамические массивы, размер которых заранее неизвестен? В действительности, в современных компиляторах строки не хранятся непосредственно внутри структур. Вместо этого хранятся лишь указатели на статически или динамически выделенные блоки памяти, а непосредственно текст строки размещается именно в этих блоках. Этим и объясняется смущающий начинающих программистов на Delphi и С++ Builder эффект, когда при попытке определить размер строки при помощи SIZEOF получается, что любая строка занимает 4 байта, а при записи структуры, содержащей поля строкового типа, в файл, вместо текстовых строк появляется странного вида «мусор» длиной в четыре байта. Исключением из этого правила являются только старые паскалевские строки фиксированной длины и массивы символов (char) в Си, но оба эти типа в настоящее время употребляются довольно редко.
Кроме того, ссылки на данные вместо самих данных также используются для хранения объектов (тех, которые «экземпляры классов») и динамических массивов. Если Вы захотите, Вы можете набросать небольшую программку для поиска всех возможных ссылок в коде программ. Базовая идея проста: код программы и статические переменные, как правило, имеют весьма небольшой объем по сравнению с максимально возможным размером адресного пространства в Win32 (4 гигабайта, если не вдаваться в тонкости устройства Windows). А потому мы можем с высокой вероятностью считать ссылками на код или данные все 32-разрядные числа в программе, которые попадают в промежутки адресов, занятые программой или данными. Чтобы проверить все это на практике, так сказать, «потрогать руками», нужна программа, извлекающая из исполняемого файла все четырехбайтные значения, которые теоретически могут быть адресами. Если Вы уже попробовали написать утилиту для поиска нецелочисленных данных и проверили ее в действии, проблем с программированием у Вас не возникнет. Самое сложное – извлечь из заголовка PE-файла адреса начала каждой секции в памяти, размеры этих секций и смещения начала каждой секции в файле. Если Вы не хотите сразу же погружаться в изучение структуры PE-заголовка (а рано или поздно этим все же придется заниматься), на первых порах Вы можете ограничиться ручным вводом этих данных. А теперь разберемся, что Вы собственно написали. А написали Вы ни что иное, как одну из частей обыкновенного дизассемблера, занимающуюся поиском ссылок в коде программ. Разумеется, настоящие дизассемблеры используют для поиска ссылок гораздо более сложные алгоритмы, но нам сейчас нужен как раз такой простейший инструмент. Разумеется, поиск вообще всех возможных ссылок на данные – само по себе занятие малополезное, но если немного поразмыслить… Если немного поразмыслить, у такой программы появляется довольно неожиданное применение: поиск начальных адресов структур и массивов по одному или нескольким известным полям (элементам).
Так что не откладывайте эту программу в дальний угол винчестера – она нам очень скоро пригодится. Итак, предположим, что Вы знаете о структурах и массивах все, что положено знать начинающему программисту, а именно: что они есть, что в них можно хранить данные, и, самое главное, как в программах обращаются к отдельным элементам этих структур и массивов. Более того, Вы даже знаете, что массивы и структуры можно комбинировать весьма удивительными и изящными способами: создавать массивы массивов (они же двухмерные массивы), массивы структур, массивы массивов структур, ну и так далее. Абсолютное большинство компилирующих языков при хранении данных структурированных типов придерживаются принципа: поля и элементы массивов хранятся в памяти в том порядке, в каком они определены в исходных текстах программы. Ну и, разумеется, порядок полей неизменен для всех данных одного типа. Само по себе это, конечно, мало что дает – ведь исходников-то у нас нет, но тут в игру вступает психология. Да-да, программисты - тоже люди, а изучение их психологии, даже поверхностное, иногда помогает лучше понять творения программистов (то бишь программы). В частности, многим программистам свойственно стремление к логике и элементарному порядку в исходных текстах. Например, если в ходе исследования неких данных Вам удалось установить, что первое и второе поле структуры – это указатели на фамилию и имя, то третье поле структуры скорее всего окажется указателем на отчество, а не закодированным размером обуви или цветом волос. Или, если речь идет о заголовке архива, за именем файла наверняка последует смещение сжатого файла в архиве (плюс-минус некоторая константа), размер этого файла в сжатом виде и контрольная сумма. Причем именно в таком порядке – это традиция, которую программисты нарушать не любят. Если Вы кроме крэкинга занимаетесь программированием, поразмыслите о том, как бы Вы разместили информацию, будь Вы на месте автора программы – и, возможно, это будет наиболее короткий путь к пониманию структуры данных.
Именно понимание программистских традиций, « неписанных законов» позволили исследователям недр Windows NT без особых сложностей разобраться с параметрами вызовов Native API – им потребовалось лишь изучить общедоступную документацию, понять, как мыслят программисты в Microsoft и немного повозиться с отладчиком. Но вернемся к нашим структурам. Проведем небольшой эксперимент: возьмем все тот же Delphi и определим пользовательский тип my_record (в Паскале структуры принято называть записями): type my_record=record a:byte; b:word; c:dword; end; А теперь попробуем подсчитать, какова длина такой записи в байтах. Если просто сложить длины полей, входящих в запись, должно получиться 1+2+4=7 байт. Но в действительности все обстоит несколько иначе: sizeof (my_record)=8! Чтобы выяснить, почему так случилось, определим в программе переменную my_var типа my_record и попытаемся присвоить полям этой переменной значения: my_var.a:=1; my_var.b:=2; my_var.c:=$ABCDEF10 (надеюсь, Вы внимательно читали предыдущую главу и уже догадались, зачем я присвоил третьему полю столь странное значение). После компиляции мы получим следующий код: :00452100 C605005C450001 mov byte ptr [00455C00], 01 :00452107 66C705025C45000200 mov word ptr [00455C02], 0002 :00452110 C705045C45004E61BC00 mov dword ptr [00455C04], ABCDEF10 Возникает закономерный вопрос: чем так плох адрес 455С01, что по этому адресу компилятор «не захотел» хранить данные. Ответ на этот вопрос лежит в недрах архитектуры x86. С незапамятных времен процессоры x86 выбирали данные, лежащие по четным адресам, немного быстрее, по сравнению с такими же данными, лежащие по нечетным адресам. Чуть позже процессорам стали «нравиться» адреса, кратные четырем. С совершенствованием процессоров список «хороших» адресов продолжал расширяться, появились «особенные» последовательности команд, которые выполнялись быстрее «обыкновенных» и в результате предельная оптимизация программ стала занятие настолько сложным, что стало проще доверить ее компилятору.
Для достижения максимальной производительности программисты старались размещать часто используемые данные именно по таким «хорошим» адресам. А чтобы программисту не приходилось раскладывать данные по нужным адресам вручную, в компиляторы была введена такая опция, как «выравнивание данных». Эта опция заставляет компилятор принудительно размещать данные по адресам, кратным величине выравнивания. В нашем случае данные выровнены по четным адресам, поэтому ячейка с адресом 455С01, находящаяся между однобайтным полем a и двухбайтным b, осталась не у дел. Однако если программисту требуется хранить в памяти достаточно большое количество записей, потери памяти из-за выравнивания могут оказаться неприемлемо большими, и в таких случаях выравнивание либо отключают вообще, либо при помощи служебных слов «объясняют» компилятору, к структурам каких типов выравнивание применять не надо. Использование выравнивания в программах дает один интересный побочный эффект, облегчающий изучение и извлечение данных, хранящихся в программах. Хотя формально значение байтов, находящихся в «дырках» между полями константы-структуры, не определено, на практике все известные мне компиляторы записывают туда нули (что интересно, эти «дырки» при желании тоже можно использовать для хранения данных). В результате достаточно длинный массив таких структур довольно легко определить среди прочих данных «на глаз». Лучше всего для таких целей подходят редакторы, позволяющие при просмотре изменять количество байт, отображаемых в одной строке и выделять цветом характерные последовательности байт (в частности, таким свойством обладает Hex Workshop 4) – цветные пятна образуют характерный узор, который Вы начнете легко замечать после минимальной практики. Тем более, что если отформатировать дамп так, чтобы в строке умещалось столько байт, сколько занимает одна структура, «дырки» выстроятся в вертикальную полосу. Чтобы Вам было понятнее, о чем я говорю, приведу пример массива записей и продемонстрирую, какими путями можно попытаться определить размер и назначение полей структур.
Вот код, присутствующий в одной из старых версий моей программы InqSoft Sign 0f Misery: 28AB5100 74AB5100 8CAB5100 03030D0D 00000000 01002E04 09000000 80000000 B0AB5100 D0AB5100 00000000 03000F00 00000000 0100CE04 08000000 81000000 ECAB5100 D0AB5100 00000000 03000F00 00000000 0100C404 08000000 A2000000 0CAC5100 30AC5100 48AC5100 03030D0D 00000000 0100A604 08000000 A3000000 Как видите, факт структурированности данных заметен невооруженным глазом, хотя мы ничего не знаем о том, какие именно данные хранятся в этом массиве. Достаточно очевидно, что размер одного элемента массива равен тридцати двум байтам. Надо отметить, здесь нам очень повезло в том, что размер структуры совпал с числом байт, отображаемых в одной строке. Впрочем, многие шестнадцатеричные редакторы позволяют менять этот параметр и группировать байты произвольным образом (т.е. не обязательно по 4, как это сделано в примере). Попробуем рассуждать логически. Первым делом заглянем в PE-заголовок файла программы и посмотрим начальные адреса и длины (в терминологии PE Explorer’а - Virtual address и Size of Raw Data соответственно) секций. Просуммировав эти характеристики, в первом приближении мы можем считать, что наша программа после загрузки занимает в памяти адреса с 410000h по 6005FFh (хотя, в общем случае, между секциями в памяти могут быть «дыры»). Поэтому числа, попадающие в этот промежуток, с большой вероятностью являются указателями на данные. Внимательно посмотрев на первый, второй и третий столбец, Вы можете заметить, что в этих столбцах как раз находятся числа из промежутка 410000h..6005FFh, т.е. это потенциальные указатели на данные. Нули, встречающиеся в третьем столбце - это «пустые» указатели; такие указатели широко известны в языках программирования под различными именами. В C/C++ такие указатели обозначаются как NULL, в Паскале/Delphi - nil. Попробуем посмотреть, что находится по адресам из первых трех столбцов первой строки. А находятся по этим адресам ASCIIZ-строки: 0051AB28: «Ожидать появления окна с указанным текстом в заголовке и классом» 0051AB74: «Имя класса окна» 0051AB8C: «Текст в заголовке окна» Если проверить остальные указатели, мы также обнаружим по соответствующим адресам текстовые строки.
Отлично! Теперь нам известны длина и тип трех полей структуры. Теперь обратите внимание на шестую тетраду. Числа 042E0001, 04CE0001, 04C40001 мало похожи на осмысленные данные, что в шестнадцатеричной системе, что в десятичной. Но вот если четырехбайтные последовательности интерпретировать не как DWORD, а как два идущих подряд WORD’а, то данные начинают выглядеть менее странно: (1,1070), (1,1230), (1,1220). Правда, мы не можем быть уверенными (как я уже говорил, такова обратная сторона метода, базирующегося на наблюдениях и предположениях), что первые два байта – это действительно одно поле типа DWORD, а не два поля типа BYTE – имеющаяся выборка слишком мала. Чтобы проверить это, необходимо исследовать код программы, который обращается к этому массиву. Но это - тема следующей главы. Если бы я привел более длинный кусок массива, было бы более очевидно, что четвертая тетрада на самом деле состоит из четырех полей типа BYTE, причем первые два из них принимают значения от нуля до пяти. А также то, что если первый или второй байт четвертой тетрады равен нулю, то вторая или третья тетрада соответственно будет хранить пустой указатель. В общем, определение типов полей по их значениям и структуре массива – занятие, требующее прежде всего наблюдательности и логического мышления. А пока разберем некоторые тонкости, о которых я умалчивал до этого момента. Вы, наверняка заметили, что, приведя в качестве примера некий кусок кода, я просто сказал «это массив структур», не приведя никаких объяснений, каким образом я обнаружил этот код в программе. На практике задача обычно ставится несколько иначе: по некоторым известным данным необходимо найти в программе область, где эти данные хранятся в структурированном виде, разобраться, какие еще данные, помимо уже известных, содержит эта структура, и затем извлечь то, что нам было неизвестно. То есть, когда мы хотим извлечь некую информацию из программы, мы ищем не «то, не знаю что», а имеем некоторое представление, что нам нужно и как оно может выглядеть.
Поэтому если Вам точно ( или даже приблизительно) известно хотя бы одно из значений, входящих в массив, Вы можете приступить к поиску этого значения внутри программы при помощи методов, описанных в предыдущей главе. И если поиск окажется успешным, Вы будете знать расположение одного из элементов массива. Кроме того, я продемонстрировал расшифровку отдельных полей, но не сказал ни слова о том, с какого из этих полей начинается описание структуры. Иными словами, мы все еще не знаем, находится ли ссылка на строку по адресу 0051AB28 в начале структуры, в середине или же вообще является последним полем записи, а следующая за ней ссылка на адрес 0051AB74 относится уже к следующему элементу массива. Если вспомнить «психологию программиста», о которой я говорил выше, и проанализировать содержимое строк, то принадлежность всех трех ссылок к одной и той же записи достаточно очевидна, но мы не будем упрощать себе задачу и попробуем честно добраться до приведенного массива. К сожалению, для полного представления о решаемой задаче необходима сама программа, привести которую в данной главе невозможно по причине ее большого объема, поэтому мне придется ограничиться только демонстрацией наиболее важных ее кусков. Итак: у нас есть программа, которая состоит из интерпретатора байт-кода и оболочки, позволяющей писать скрипты, которые затем транслируются в байт-код. Внутри оболочки содержится массив, описывающий команды, используемые в этой программе и байт-коды, соответствующие каждой команде, и нам требуется извлечь из программы список байт-кодов и названий команд, которые этим кодам соответствуют. Сама оболочка запакована при помощи UPX, но ее распаковка никакой сложности не представляет. Названия команд известны каждому запустившему программу, поскольку полный список команд отображается в оболочке. Возьмем любую команду, например «Ожидать появления окна с указанным текстом в заголовке и классом» и попробуем найти этот текст в коде программы. Никаких особых ухищрений для этого не потребуется, при помощи функции поиска в HexWorkshop находим, что искомая строка встречается в файле в единственном экземпляре по смещению 1154856 байт от начала файла.
Далее, при помощи любого Offset->RVA конвертера (утилиты, преобразующей смещения в файле в относительные виртуальные адреса (RVA)) определяем, по какому адресу будет размещен 1154856-й байт программы после загрузки этой программы. Получаем, что эта строка в памяти располагается начиная с адреса 51AB28h. Теперь вспомните то, что говорилось о методах хранения строк внутри структур, и Вам станут очевидны наши дальнейшие действия. Следующий этап будет заключаться в поиске по всему коду программы ссылок на нужную строку, то есть 32-битной константы 51AB28h. Такая константа нашлась сразу же, причем в единственном экземпляре по смещению 1229C0h от начала файла. 128-байтный блок, начинающийся с этой константы, я и привел в качестве примера несколькими абзацами выше. Теперь Вы имеете представление, при помощи каких приемов можно добраться до нужного массива. Как видите, здесь все достаточно просто. Теперь нам надо выяснить, с какого байта начинается какой-либо элемент приведенного массива. На практике обычно проще всего найти адрес первого элемента массива (или нулевого, если использовать нумерацию, принятую в Ассемблере или Си). Сделать это можно двумя способами: поиском ссылок на начало массива либо анализом самих данных с целью поиска верхней и/или нижней границы массива. Первый способ базируется на том, что в программе почти наверняка найдется явная ссылка на первый элемент массива, и что адрес этого элемента больше либо равен адресу одного из известных нам элементов, входящих в массив. В применении к нашему примеру это означает, что если смещение одного из найденных нами элементов структуры равно 1229C0h (RVA для этого смещения равно 522BC0h), то начало массива, очевидно, находится не ниже этого адреса. Следовательно, если выбрать из программы все константы меньше либо равные 522BC0h (а для этого нам и нужна соответствующая программа, о которой я уже упоминал), среди них окажется и ссылка на первый элемент массива. При этом, если рассортировать найденные ссылки по убыванию их величин, велика вероятность, что ссылка на начало массива окажется в числе первых. Продемонстрирую это примером.
Допустим, Вы выяснили, что ячейка по адресу 450080h входит в состав массива. Выбрав все ссылки, указывающие на адреса не ниже 450080h, Вы получили следующий набор: 450000h, 49FFD0h, 49FCD4, 49FCD0h и так далее. Из всех найденных Вами адресов теоретически наиболее вероятным адресом будет являться 450000h. Почему? Причина в том, что информация из длинных предварительно инициализированных массивов обычно (но не всегда!) считывается при помощи конструкций вида my_arr[i], где значение i явно не указано и на этапе компиляции определено быть не может. И потому, чтобы обратиться к i-му элементу массива, в общем случае программа должна вычислить адрес этого элемента по формуле адрес_элемента = начальный_адрес_массива + размер_элемента * (номер_элемента - номер_первого_элемента). Как мы видим, в этой формуле в явном виде присутствует начальный адрес массива, который мы и ищем в программе. Кроме того, если этот массив передается в качестве аргумента в какую-либо процедуру или функцию, то весьма вероятно, что в программе встретится команда push адрес_начала_массива. Это происходит потому, что передача всего массива в процедуру через стек встречается в аккуратно написанных программах достаточно редко, обычно передается лишь ссылка на первый элемент (которая нам и нужна). Однако «гладко было на бумаге», а в реальности существует немало «подводных камней», заметно осложняющих использование предложенного метода, а иногда даже и делающего этот метод неприменимым. Прежде всего, проблему могут создать обращения к элементам с явно указанными номерами. Допустим, что в программе кроме конструкций вроде my_arr[i] имеются обращения к конкретным элементам, например a=my_arr[2] или if b>my_arr[10] then do_something. Если адреса значений my_arr[2] и my_arr[10] могут быть вычислены на этапе компиляции, то хороший оптимизирующий компилятор их вычислит, подставит в код, а в программе, кроме начального адреса массива, появятся также адреса второго и десятого элемента массива. И тогда при поиске начала массива по предложенному методу Вы можете найти не первый элемент, а второй или десятый – это уж как повезет. Во что хороший оптимизирующий компилятор может превратить исходный код – это вообще тема большая и интересная.
Например, в Delphi допустимы массивы, начальный элемент которых может иметь любой целочисленный индекс, в том числе и отрицательный, т.е. вполне корректно использование выражений вида a:=my_arr[-10]. Да и менее экзотичный массив вроде array [10..100] of my_record не так прост, как может показаться с первого взгляда: посмотрим, как будет выглядеть приведенная выше формула адреса произвольного элемента массива для этого случая. адрес_элемента = начальный_адрес_массива + размер_структуры_my_record * (номер_элемента – 10) Очевидно, что эту формулу можно переписать как: адрес_элемента = (начальный_адрес_массива – размер_структуры_my_record * 10) + (размер_структуры_my_record * номер_элемента) Вы, наверное, заметили, что выражение в первой скобке – константа, которая для предварительно инициализированного массива может быть легко вычислена на этапе компиляции. Эта константа представляет собой адрес виртуального нулевого элемента массива (не секрет, что большинство компиляторов без специальных указаний не проверяют выход за границы массива, а потому Вы можете обратиться в программе даже к тем элементам, которые в действительности не существуют). Поэтому в программах на Паскале/Delphi вместо ссылок на истинное начало массива для экономии одной операции вычитания может использоваться вычисленный таким образом адрес «нулевого» элемента, не существующего в действительности. Подобные приемы иногда используются и при программировании на ассемблере, когда для ускорения программы вместо пары команд dec ebx mov eax, [OFFSET my_arr+ebx*4] используется единственная команда mov eax, [(OFFSET my_arr-4)+ebx*4], в которой значение (OFFSET my_arr-4) вычисляется на этапе компиляции. Как видите, поиск массива по ссылке на первый элемент – процесс не всегда простой, и когда Вам потребуется им воспользоваться, будьте готовы к возможным трудностям. Однако существует второй способ поиска массивов, который более прост в реализации, независим от использовавшегося для сборки программы компилятора и не требует каких-либо инструментов, помимо хорошего шестнадцатеричного редактора и наблюдательности. Принцип, заложенный в основу этого метода, очень прост.
Хранящиеся в массиве данные носят осмысленный характер, а при просмотре в шестнадцатеричном редакторе структурированность этих данных довольно легко заметна. А потому, если Вам известна точка, заведомо находящаяся внутри массива, двигаясь от этой точки вверх или вниз, Вы сможете обнаружить начало или конец массива по изменению внешнего вида данных. Если посмотреть на приведенный в качестве примера кусок массива, регулярность данных в этом массиве видна невооруженным глазом: нолики идут под ноликами, единички – под единичками, указатели – под указателями, ну и так далее. Если бы я привел дамп всего массива целиком (он довольно объемный), Вы бы смогли убедиться, что внешний вид всех остальных записей массива очень похож на эти четыре строки. Однако как только Вы дойдете до начала или конца массива, внешний вид данных, скорее всего, изменится – ведь перед массивом и после него хранятся совсем другие данные с другой структурой (хотя надо отметить, что если подряд идут да массива с однотипными данными, то граница межу ними может быть не столь явной). 80000000 00800000 20000000 00400000 40000000 78985100 88985100 98985100 A8985100 B8985100 C8985100 E4985100 00995100 1C995100 30995100 68000000 C49B5100 E09B5100 00000000 03000E00 01000000 00006400 00000000 97000000 009C5100 E09B5100 3C9C5100 03030E0D 01000000 00006E00 00000000 6B000000 Внимательно посмотрев на приведенный кусок, Вы можете видеть, что массив начинается с восьмой тетрады второй строки. Почему? Во-первых, достаточно очевидно, что первые две строки заметно отличаются от третьей и четвертой, следовательно, границу между массивами нужно искать именно во второй либо в третьей строке. Первая и вторая тетрада второй и третьей строки похожи на указатели на текстовые строки (хотя, если копнуть чуть глубже, можно обнаружить, что только в третьей строке эти ссылки действительно указывают на названия команд). А вот четвертая, пятая, шестая и седьмая тетрады также являются указателями и внешне сильно отличаются от соответствующих тетрад третьей и четвертой строки.
Очевиден вывод: все данные по седьмую тетраду второй строки включительно не принадлежат массиву. Осталось разобраться с восьмой тетрадой: она ничем не выделяется на фоне последующих двух, поэтому мы с чистой совестью можем отнести ее к нашему массиву и сказать, что массив начинается именно с нее. Дальше, если проанализировать весь массив, выяснится, что первое поле – уникально для каждого элемента массива, и его значение не превышает 255 (то есть первый элемент структуры - байт). Логично предположить, что это и есть код команды, который мы и искали. Конец массива отыскивается аналогичным способом; поиск конца массива, пожалуй, даже проще, поскольку очевидно, что размер массива в целом должен быть кратен размеру одного элемента этого массива. Увы, и этот метод не свободен от недостатков. Во-первых, для успешного использования этого метода необходимо, чтобы элемент массива можно было легко отличить от данных, не имеющих к массиву никакого отношения. Однако так бывает далеко не всегда, например, таблица констант для реализации CRC по алгоритму WiseByte выглядит как набор из 256 псевдослучайных чисел, не имеющих каких-либо характерных признаков, видимых невооруженным глазом. Во-вторых, проблему представляют случаи, когда в одном массиве хранятся разнородные данные. Возможность хранения разнородных данных в структуре предусматривается некоторыми языками программирования («структуры с вариантами» в Паскале и объединения (union) в Си). И в-третьих, желательно, чтобы данные, предшествующие массиву и следующие за ним, заметно отличались по внешнему виду, что совершенно не обязательно выполняется на практике. Более того, программисты, исходя из стремления к порядку в исходных текстах, очень часто располагают однотипные массивы последовательно, чем отнюдь не облегчают жизнь крэкерам. Как видите, оба способа обладают существенными недостатками, сильно зависят от «человеческого фактора», не гарантируют стопроцентного результата (впрочем, в крэкинге результат вообще никто гарантировать не может), а область их действия – ограничена.
Что еще можно сделать, чтобы поднять эффективность этих техник? Ответ прост – необходим синтез обоих методов! Результаты, полученные при помощи второго метода, могут быть проверены поиском указателя на первый элемент в коде программы. И наоборот, если Вы нашли возможную ссылку на начало массива, посмотрите, как выглядит тот участок программы, на который указывает эта ссылка, похож ли он на начало массива, или необходимо внести поправки в свои рассуждения. Вообще, крэкеру (да и не только крэкеру) стоит взять на вооружение следующий принцип: если результат, полученный одним способом, может быть проверен другим способом, эту нужно выполнить проверку. В конечном итоге следование этому принципу позволит Вам избежать ошибок, и из-под Вашей руки выйдет гораздо меньше недопатченных и криво взломанных программ. Поэтому я сейчас поведаю Вам про третий метод исследования массивов, немного варварский, но весьма результативный. Этот метод позволяет не только обнаружить начало массива, но и выяснить действительные размеры каждого элемента структуры и определить, какой из элементов открывает определение этой структуры. Суть метода проста: добравшись до одного из элементов массива, Вы начинаете при помощи шестнадцатеричного редактора изменять отдельные байты и наблюдать, как эти изменения скажутся на работе программы. При известной наблюдательности этот метод позволяет быстро и без особых усилий понять внутреннюю логику организации данных; впрочем, изменение кода программы (значений регистров, содержимого памяти) с целью «посмотреть, что будет, если…» - один из наиболее популярных подходов в крэкинге, доказавший на практике свою эффективность. Все, что я рассказал выше об изучении массивов, не следует воспринимать как единожды данное откровение, в котором нельзя ничего добавить, отнять или изменить. Напротив, предложенные методы можно и нужно комбинировать, адаптировать под конкретные задачи, которые будут вставать перед Вами. И если я чего-то не упомянул или о чем-то умолчал, то исключительно по той причине, что невозможно описать в одной короткой главе все возможные случаи, с которыми Вы можете столкнуться в процессе изучения программ.А потому повторю еще раз: данный материал – это не «инструкция по крэкингу», но, прежде всего, базовый материал для самостоятельных рассуждений, выводов и экспериментов. Ну вот, теперь знакомство с приемами поиска данных в исполняемых файлах программ в основном закончено. Осталось лишь изучить специфические техники поиска данных «на лету», то есть в работающих программах, а также некоторые особо экзотические приемы извлечения данных из программ. [C] CyberManiac Содержание Далее
Куда попадают данные.
На протяжении уже двух глав я рассказывал о техниках поиска данных всевозможных типов. Подобно муравьям, мы трудились, учась собирать по крупицам полезную информацию, которая могла бы помочь понять структуру и смысл этих данных. И теперь вы знаете, как «просеивать» мегабайты листингов, извлекая из них константы и указатели, как вести поиск переменных в адресном пространстве работающей программы. Все это время мы рассматривали «живые», находящиеся внутри работающей программы данные отдельно от «мертвых», тихо лежащих на жестком диске и никак себя не проявляющих. И вот пришло время осуществить синтез, увидеть диалектическую взаимосвязь между этими двумя формами существования данных, и, главное, понять, какую практическую пользу из этой взаимосвязи можно извлечь. Нетрудно заметить, что любые данные, хранящиеся на диске, в действительности предназначены для обработки какой-либо программой, а, стало быть, рано или поздно будут загружены в оперативную память (если, конечно, это полезные данные, а не мусор, подлежащий удалению), где над ними будут производиться всяческие действия. А результаты этих действий так или иначе отобразятся в мире «по нашу сторону экрана» при помощи одного из многочисленных устройств ввода-вывода, чтобы пользователь мог их увидеть, услышать или ощутить каким-либо иным образом. И пока эти данные будут проходить свой непростой путь от загрузки с винчестера до отображения на экране монитора, их можно «выловить» из адресного пространства программы или даже с экрана (то есть, конечно, не совсем с экрана, а из видеопамяти). Более того, информация, которую очень непросто расшифровать, разглядывая файл в шестнадцатиричном редакторе, при загрузке соответствующей программой нередко бывает представлена в памяти в виде структур с весьма незамысловатым внутренним устройством. Да и само наблюдение за процессом загрузки данных может дать множество полезной информации, и в этой главе мы посмотрим, как такую информацию можно извлекать. Когда-то давным-давно, когда Windows еще был девяносто пятым, защиты – простыми, а авторы защит - наивными, серийные номера извлекались из программ следующим образом: устанавливались точки останова на все функции WinAPI, при помощи которых мог считываться серийный номер (благо их не так много).Затем нужно было вызвать окно регистрации, ввести в него любые данные и посмотреть, какая из точек останова сработает. Дальше начиналось самое интересное: поскольку то были старые добрые времена, непуганые разработчики для проверки правильности серийных номеров частенько использовали обычное сравнение двух текстовых строк, причем для сравнения использовался банальный вызов функции lstrcmp (или ее самодельного аналога), два параметра которой являлись указателями на сравниваемые строки. И чтобы получить правильный серийник, требовалось лишь найти нужную функцию и посмотреть на ее параметры. Конечно, те времена давно прошли, и ныне очень, очень редко встречаются программы, в которых серийный номер хранился бы в открытом или «как бы зашифрованном» при помощи команды XOR виде. Но для крэкера как раз важен не столько сам факт хранения данных в открытом виде, сколько идея: скормив программе заведомо неверные данные, пронаблюдать за тем, как программа эти данные будет «переваривать» и проверять на корректность. Да и наблюдение за процессом «заглатывания» данных программой может стать источником ценных идей. Приведу пример из собственной практики. Однажды я изучал некую программу на предмет «исправить пару переходов, чтобы она лучше и дольше работала». Нужную «пару переходов» я вычислил за считанные минуты, а патчинг этих байт непосредственно в памяти успешно решал мою проблему на время одной сессии работы с программой. Но вот исправление тех же байтов в исполняемом файле неизбежно приводило к «падению» программы сразу после запуска. Нетрудно было догадаться, что программа неким образом контролировала собственную целостность, и, скорее всего – проверкой контрольной суммы. Это предположение подтверждалось и подозрительно большим временем загрузки программы (компьютеры тогда были намного медленнее, поэтому иногда следы работы защитных средств были видны, что называется, невооруженным глазом). Решение тоже было достаточно очевидным – найти функцию вычисления контрольной суммы, посмотреть, какой результат эта функция должна была возвращать в норме и либо обойти сравнение реальной контрольной суммы с эталоном, либо заставить функцию возвращать эталонное значение в любом случае.
Но как найти нужную функцию? Для начала я попытался выяснить, каким образом программа проверяет свою контрольную сумму – сканирует образ непосредственно в памяти, или все-таки проверяет то, что лежит на диске. Поскольку программа была не запакована (в те времена упаковщики вообще встречались нечасто), я просто загрузил программу при помощи loader’а из состава SoftIce (одна из полезных крэкеру функций этого loader’а как раз в том, что он передает управление отладчику сразу после загрузки подопытной программы в память). Затем я поставил аппаратные точки останова на чтение тех байт, которые я хотел изменить в файле (тут логика проста: если программа проверяет саму себя в памяти, то для этого ей придется прочитать себя) и на запись (на всякий случай) и отпустил программу на волю (то есть на исполнение). Ни одна из точек останова не сработала, из чего следовало, что программа либо не проверяет себя в памяти, либо это очень хитрая программа, которая на мою уловку не попалась. Запустив программу под filemon’ом, я увидел, что сразу после запуска эта программа поблочно читает свой собственный исполняемый файл, что навело меня на мысль о встроенной в программу проверке контрольной суммы. Дальнейшее было делом техники: прогнав программу под Bounds Checker’ом, я выяснил, что нужный мне вызов функции чтения из файла в действительности производится не из самой программы, а из DLL, которая в случае успешной проверки возвращала некое значение (а в случае неуспешной проверки – тоже значение, но уже другое) и что для работоспособности программы величина этого значения было критически важной. В этой ситуации я счел наилучшим решением выкинуть вычисление контрольной суммы файла (это ощутимо ускорило загрузку) и немного «помог» этой DLL всегда возвращать нужное мне значение. О чем эта история? Ну разумеется, не о том, что глупо помещать код проверки в DLL, где его несложно поправить. Прежде всего, я хотел показать, как наблюдение за переходом данных из «мертвого» состояния в «живое» (а именно таким переходом и является поблочная загрузка файла для вычисления контрольной суммы) может помочь обнаружить защитные механизмы.
Действительно, стоило мне понаблюдать за процессом проверки целостности файла (о котором я ранее ничего не знал, кроме факта его наличия) под API-шпионом, как я сразу же получил информацию о типе защиты и местонахождении защитной процедуры. А после недолгих экспериментов и размышлений я также узнал, какова величина контрольной суммы программы до и после внесения в нее модификаций. Вылавливание нужных данных из оперативной памяти уже давно стало неотъемлемой частью крэкинга и получило весьма широкое распространение. Если Вы уже пробовали самостоятельно взломать или хотя бы посмотреть на внутренности какой-либо программы, то, возможно, уже столкнулись с упаковщиками исполняемых файлов (или, если быть до конца точным, с файлами, обработанными такими упаковщиками). Разумеется, крэкеру во всех этих упаковщиках и навесных защитах интересно одно: методы их снятия. Очевидно, что упаковка программ – процесс обратимый и проблема лишь в том, чтобы найти способ обращения этого процесса, проще говоря – распаковать ее. Существует два подхода к распаковке. Можно проанализировать алгоритмы работы встроенного в программу навесного модуля, осуществляющего раcпаковку и самостоятельно воспроизвести эти алгоритмы в виде независимой программы. Этот метод обычно долог и труден. А можно оставить все хлопоты по распаковке «навесному» модулю-распаковщику, встроенному в исполняемый файл, а потом воспользоваться результатами его трудов, «выдернув» распакованную и готовую к употреблению программу из памяти компьютера. В таком подходе мне определенно видится изящество и утонченность – вместо того, чтобы брать штурмом алгоритмы распаковки, мы, фактически, заставляем автора навесной защиты сражаться с собственным творением. Конечно, авторы защит тоже не дремлют – редкий упаковщик не уродует до неузнаваемости таблицу импорта, не содержит в антиотладочного кода или средств противодействия дампингу, и преодоление этих трудностей требует от крэкера гораздо больших усилий, чем собственно снятие дампа.
Однако сама идея как нельзя лучше раскрывает тему этой главы – если нет возможности (сил, времени, желания) «расшифровать» нужные данные вручную, стоит подумать о том, где можно найти готовые алгоритмы декодирования, каким образом их применить и как воспользоваться результатом их работы. Особенно интересные и впечатляющие результаты дает сочетание предлагаемой технологии с глубоким патчингом программ в памяти. Недавно мне в руки попался экземпляр MoleBox –представителя (надо сказать, не самого совершенного) нового поколения защит, где упаковке подвергается не только исполняемый файл приложения, но и все остальные файлы, входящие в комплект программы, после чего все эти упакованные файлы сливаются в один монолитный исполняемый файл («ящик» в терминологии MoleBox). Сам EXE-файл программы модифицируется таким образом, что вызовы функций API для работы с файлами подменяются вызовами внутренних функций защиты, после чего программа может одинаково успешно обращаться как к файлам на жестком диске, так и к файлам, находящимся внутри «ящика» (в MoleBox к файлам из «ящика» возможен доступ только на чтение). Кстати, базовая информация о принципах работы MoleBox честно приведена в документации к программе, поэтому позволю себе в очередной раз повторить совет внимательно читать документацию к исследуемым программам. После недолгих экспериментов удалось выяснить, что «виртуальная директория», в которой работает защищенное приложение, содержит все файлы программы, и извлечь их оттуда не составляет никакого труда. При помощи манипуляции значениями регистров и содержимым стека в SoftIce мне удалось вызвать FindFirstFile/FindNextFile и вручную прочитать список имен всех файлов, находящихся в «ящике» программы, кроме самого исполняемого файла (который пришлось выковыривать более традиционными методами). Дальше все было еще проще в теории и еще тяжелее и нуднее на практике: выделение памяти под буфер, чтение файлов в этот буфер и последующее сохранение в другой файл.
Конечно, проделывать все эти операции вручную – занятие крайне трудоемкое, и если Вы захотите повторить мой эксперимент, я советую Вам не упражняться в играх с регистрами, а набросать соответствующую программку на ассемблере, внедрить ее в адресное пространство «жертвы», и получить тот же самый результат, но в несколько раз быстрее. Еще одно применение предлагаемого метода – декодирование данных, имеющих сложную или неочевидную структуру. Например, при сохранении множества записей, содержащих как текстовую, так и числовую информацию, формат результирующего файла может быть совершенно неочевиден. К примеру, массив структур, состоящих из одного текстового (обозначим его буквой T) и одного числового поля (обозначим его как N) может сохраняться в файле как минимум двумя способами: T1, N1, T2, N2, T3, ТN3, … или как N1, N2, N3, … T1, T2, T3, …, где Tn, Nn – текстовое и числовое поле соответственно n-й записи в массиве. Поскольку текстовые данные отличить от числовых несложно даже по внешнему виду, в данном конкретном примере никаких сложностей с извлечением из файла элементов массива скорее всего не возникнет. Но представим, что текстовых полей – несколько, а сохраняемые в этих полях значения – внешне очень похожи. И что каждая из структур в массиве содержит подструктуры, сохраняемые в том же самом файле подобным же образом. Задача расшифровки внутреннего формата файла уже не кажется такой тривиальной, не правда ли? Однако вспомним наш краткий курс психологии программиста и попробуем представить, как нормальный программист организует хранение тех же данных в памяти. Скорее всего, он создаст банальный массив структур, поэлементно заполнит его значениями из файла и будет обращаться к нему так, как обращаются к любым другим массивам. Если логика задачи предполагает, что в загруженные данные потребуется добавлять новые элементы или удалять имеющиеся, то вместо обычного массива скорее всего будет одно- или двухсвязный список, в котором каждый элемент помимо собственно структуры будет содержать еще указатель на предыдущий и последующий элементы списка.
А дальше… Дальше задача полностью аналогична описанной в предыдущей главе задаче по «раскалыванию» неизвестного формата и извлечению данных, с той лишь разницей, что работать мы будем не с кодом приложения, а с обрабатываемыми этим приложением данными. Увы, данный прием не универсален – например, буферизованная обработка данных (т.е. такая, при которых обрабатываемые данные не переносятся в память целиком, а подгружаются по мере необходимости) не позволит расшифровать весь исследуемый файл целиком за один раз. Вообще, количество всевозможных «особых случаев» весьма велико, и рассмотреть их все в этой небольшой работе практически нереально. И если Вам придется столкнуться с такой нетривиальной программой, успех будет полностью зависеть от Вашей сообразительности, настойчивости и терпения. С технической точки зрения изучать данные в памяти гораздо менее удобно, чем препарировать исполняемый файл на «винчестере», да и инструментов для интеллектуального поиска данных в чужом адресном пространстве не так уж много. Кроме того, если информация загружается в динамически выделяемые области памяти, исследуемые данные будут от запуска к запуску «плавать» по адресному пространству программы, располагаясь каждый раз по новым адресам. А это будет совсем уж нехорошо – проделывать массу рутинной работы только из-за того, что программу угораздило выделить очередной кусок памяти на сотню байт выше, чем в предыдущий сеанс. Поэтому позаботимся о создании элементарных удобств для работы. Прежде всего нам понадобится перенести наше поле деятельности из чрезвычайно нежной оперативной памяти на гораздо более жесткий диск, где можно будет проводить любые эксперименты не опасаясь, что случайное нажатие «не той» клавиши приведет к потере результатов длительных исследований. Вы наверняка уже догадались, что самым простым решением было бы снятие дампа с нужных областей памяти и сохранение этого дампа на «винчестере». Увы, все далеко не так просто, как хотелось бы. Во-первых, Вам придется озадачиться поиском подходящего инструмента.
Классические дамперы из крэкерского арсенала Вам не помогут, поскольку они предназначены для снятия дампа программы, но не данных с которыми эта программа работает. Более того, случайный захват данных для классического дампера – явление крайне нежелательное, поскольку основное назначение дамперов – распаковка программ и получение работоспособного EXE-файла с минимальным количеством избыточной информации внутри, а не анализ «мусора», перемалываемого программой в процессе ее работы. А вот нам нужен именно этот «мусор». Поэтому придется либо писать программу для «правильного» дампинга своими силами, либо извлекать нужные данные вручную при помощи отладчика, позволяющего сохранять содержимое кусков памяти в файлах. Во-вторых, данные могут быть разбросаны по адресному пространству программы, но сдампить их нужно за один сеанс отладки. Не так уж редки программы, в которых часть информации хранится, к примеру, в секции инициализированных данных, а другая часть – в динамически выделяемых блоках памяти, и если сдампить содержимое динамической памяти программы, но забыть про инициализированные данные, такой дамп скорее всего можно выбросить. Нет никакой гарантии, что при следующем запуске, когда Вы выясните, что указатели на динамические блоки находятся в секции инициализированных данных, и что эту секцию тоже нужно дампить, эти указатели будут указывать на те же адресам, что и в прошлый сеанс работы. Поэтому я настоятельно не рекомендую экономить место на диске, и делать полный снимок всех секций программы, поскольку лишнее всегда можно выкинуть, а вот недостающие данные взять будет неоткуда. И в-третьих, снимая дамп, никогда не забывайте записывать базовые адреса тех кусков памяти, которые Вы дампите – в самом ближайшем будущем они Вам определенно понадобятся. Но допустим, что мы аккуратно сделали полный снимок подопытной программы, и теперь все ее секции аккуратно разложены на нашем винчестере в идеальном порядке. Что дальше? Возьмите хороший шестнадцатиричный редактор и загрузите в него какую-либо из секций.
Теперь настройте этот редактор так, чтобы вместо смещений в файле он показывал смещения относительно базового адреса этой секции в памяти. То есть если Вы сбрасывали на винчестер кусок памяти с адреса 401000h по 402000h, после соответствующей настройки смещение первого байта файла должно отображаться именно как 401000h, а не как 0. В частности, такую операцию умеет выполнять HIEW: для этого необходимо нажать Ctrl-F5 и ввести новую базу. Если Ваш шестнадцатиричный редактор делать такие фокусы не умеет, значит, Вы выбрали недостаточно хороший шестнадцатиричный редактор и Вам будет заметно сложнее постигать разверзнувшиеся перед Вами глубины программы. Возможно даже, что несовершенство Вашего инструментария подвигнет Вас на написание нового, уникального шестнадцатиричного редактора с доселе невиданными возможностями – великие дела совершались по куда менее значительным поводам, чем отсутствие подходящего инструмента для копания в кодах. В принципе, можно обойтись даже без автоматического пересчета базового смещения, но тогда Вам придется проделывать необходимые вычисления в уме, и за всей этой шестнадцатирично-арифметической рутиной Вы можете не прочувствовать до конца всю силу и эффективность предлагаемого метода. После того, как Вы проделаете все, о чем я говорил, внимательно посмотрите на экран монитор, включите на полную мощность свое воображение и представьте, что Вы разглядываете не кучу байтов, тонким слоем рассыпанных по поверхностям жесткого диска, а мгновение из жизни программы, которое Вы вольны сделать сколь угодно долгим. И то, что Вы видите в окне шестнадцатиричного редактора, по сути, ничем не отличается от того, что Вы бы увидели в окне отладчика, разглядывая память «живой» программы. Вы точно так же можете, следуя указателям, бродить по адресному пространству, дизассемблировать куски кода, искать константы и переменные (поскольку «замороженная» переменная есть ни что иное, как константа) по их значению – в общем, делать с программой все то, о чем я говорил в двух предыдущих главах. Однако помимо «честных» методов поиска, требующих хотя бы минимального знания о структуре и типизации искомых данных, есть еще один нехитрый прием, не требующий ничего, кроме терпения.
Суть метода проста: Вы вырываете из файла на жестком диске небольшой кусок и пытаетесь найти точно такой же кусок в памяти программы. При некотором везении в памяти идентичный кусок обнаружится, а его местоположение укажет Вам, куда программа загрузила соответствующие байтики из файла. После этого Вы можете попытаться логически проанализировать наблюдаемую картину либо просто влепить аппаратную точку останова на чтение всей прилегающей памяти (это будет очень, очень большая «точка») и посмотреть, что будет делать с данными подопытная программа. Несмотря на то, что такой поиск внешне сильно напоминает пресловутый «метод научного тыка», в его основе лежит вполне логичная идея: если информация из файла переносится в память без потерь и существенных изменений, соответствующие элементы структур в файлах и в памяти будут идентичны. Проще говоря если где-то в файле хранилось двухбайтное число 12345, есть вероятность, что оно и в памяти будет выглядеть двухбайтным числом 12345. Хотя, конечно, вполне возможны программы, загружающие числа типа «байт», но обрабатывающие их как 10-байтные с плавающей точкой. Разумеется, этот метод можно усовершенствовать, заметно повысив его эффективность: например, брать не случайные куски, а содержащие осмысленный текст – тогда Вы будете знать, что ищете текстовое поле, на которое почти наверняка будет существовать указатель, а сам этот указатель скорее всего будет входить в структуру, а структуры будут организованы в массив или список… Как видите, хотя набор базовых приемов не так уж велик, и каждый из них отнюдь не свободен от недостатков и ограничений, но, комбинируя и адаптируя их под особенности конкретных программ, можно весьма многого добиться. Рассмотренные выше техники добывания данных из недр «живой» программы имели одно общее свойство – по отношению к программе их можно было охарактеризовать как «принуждение». Посудите сами – программа спокойно себе работает, никого не обижает, но тут в ее спокойную и размеренную жизнь врывается крэкер с отладчиком наголо и начинает направо и налево дампить секции и разбрасывать точки останова.
Такой подход, конечно, приносит свои плоды – но в некоторых случаях проблемы извлечения данных проще решать не «грубой силой», даже если это сила интеллекта, но хитростью, использованием всевозможных лазеек в коде программы, или даже через нетривиальное использование стандартных средств ОС или самой программы. Практика показала, что если программу вежливо и в изысканной форме попросить, она вполне может поделиться с Вами нужными Вам данными. В Windows роль вежливых просьб играют системные сообщения – традиционное средство, используемое для огромного количества всевозможных действий – от элементарного закрытия окна до рассылки программам уведомлений о выходе операционной системы из «спячки», иначе именуемой Hibernate. Сила сообщений в Windows весьма велика, и, овладев и правильно распорядившись ей, можно получать весьма интересные результаты. Например, при помощи сообщений можно вытащить все строки из выпадающего списка (ComboBox) или таблицы (ListView), если автор программы забыл предусмотреть в своем детище более традиционный способ сохранения данных. Что для этого нужно? Только документация и некоторые навыки в программировании с использованием WinAPI. А теперь мы плавно перейдем от теории к практике и рассмотрим пример того, как можно применить эту технику для решения конкретной задачи. Но для начала – немного истории. Как-то раз у меня возникла необходимость получить полный список имен всех сообщений Windows и числовых значений, которые за этими именами скрываются. Задача, надо сказать, была совсем не праздная – этот список был мне жизненно необходим, чтобы включить его в состав моих программ. Но вот незадача – в заголовочном файле из комплекта поставки MASM32 эти имена были разбросаны по всему windows.inc в совершенном беспорядке, и меня совершенно не радовала перспектива проявлять чудеса трудолюбия, вручную выискивая и обрабатывая несколько сотен строк. Полный список, разумеется, можно было бы извлечь из заголовочных файлов последней версии Visual Studio, но, кроме того, что я вообще не являюсь поклонником данного продукта, в частности у меня не было никакого желания искать где-то дистрибутив оной «студии» и устанавливать его ради одного-единственного файла.
Однако так уж исторически сложилось, что у меня все-таки была одна небольшая часть Visual Studio – а именно утилитка, именуемая Spy++. Одна из функций этой утилиты заключалась в том, чтобы отслеживать сообщения, которые пользователь указывал в специальном окне, по-научному называемом ListBox. В этом окне как раз и отображался полный список сообщений, среди которых можно было «мышкой» отметить те сообщения, которые требовалось отлавливать. Иными словами, было совершенно очевидно, что Spy++ содержал всю необходимую мне информацию, и требовалось лишь найти способ эту информацию извлечь. Первой, что пришло мне в голову, это пропустить файл spyxx.exe через утилиту, вычленяющую текстовые строки, и затем выбрать из всех найденных строк имена сообщений. Однако после некоторых размышлений я отверг этот путь: во-первых, мне хотелось получить список сообщений в отсортированным по алфавиту точно в том порядке, в каком они находились в Spy++, а во-вторых, у меня не было желания разбирать ту кучу малу, которую обычно вываливают утилиты поиска текстовых строк. Поэтому я решил поступить проще: написал программку, которая при помощи сообщения LB_GETCOUNT определяла количество строк в нужном мне ListBox’е, а потом построчно считывала содержимое ListBox’а, в цикле отправляя ему сообщения LB_GETTEXT. Через считанные минуты у меня на винчестере покоился в виде текстового файла полный список сообщений из Spy++. После этого оставалось только извлечь из исполняемого файла числовые значения, соответствующие именам сообщений, что я и сделал при помощи методов, о которых я говорил в предыдущей главе. Если у Вес есть желание попрактиковаться в применении этих методов – можете самостоятельно попробовать извлечь эти данные, особой сложности это не представляет. Нередко для обработки и отображения данных программисты под ОС Windows используют ActiveX-компоненты, одним из полезных свойств которых является возможность получить доступ к интерфейсам такого компонента без всякой документации и заголовочных файлов.
Например, импортировав нужный ActiveX-компонент в Delphi, Вы сразу же сможете увидите свойства и методы, присущие этому компоненту. И, запрашивая значение нужных свойств и вызывая соответствующие методы, Вы скорее всего сможете научиться извлекать данные, которые этот ActiveX отображает. Более того, Вы получите возможность экспериментировать с этим компонентом в «лабораторных условиях» собственных тестовых примеров, имитирующих работу программы, из которой Вы собираетесь вытащить данные, а не непосредственно на «поле битвы» с чужим кодом. Вы можете подумать «ну и какая польза от этих экспериментов – ведь нужные данные находятся в другой программе» - но не спешите с выводами. Представьте себе, что Вам удалось внедрить свой код в исследуемую программу и получить доступ к интерфейсам нужного ActiveX… А впрочем, почему только «представьте»? Внедряйте, получайте доступ – и считывайте вожделенную информацию! И, наконец, не бойтесь пользоваться простейшими методами. Может случиться так, что один из множества инструментов автоматизации, умеющий листать страницы в указанном окне и делать скриншоты, объединенный с программой распознавания текста, поможет Вам получить распечатку защищенного от копирования телефонного справочника быстрее, чем извлечение той же информации из глубин адресного пространства при помощи отладчика. В конце-концов, если информация где-то отображается – значит, ее можно оттуда извлечь и сохранить в желаемом виде – нужно лишь изобрести подходящий метод. Но это уже совсем другая, далекая от крэкинга история. [C] CyberManiac Содержание Далее
Искусство разбивать окна.
Человек и кошка плачут у окошка, Серый дождик каплет прямо на стекло. К человеку с кошкой едет «неотложка» - Человеку бедному мозг больной свело. Федор Чистяков, «Человек и кошка» И вот, пройдя долгий и трудный путь исследователя ценных данных, упрятанных в недрах программ, мы, наконец, подошли к вратам «чистого крэкинга». Вспомните, с чего начинаются едва ли не все платные программы: с предложения зарегистрироваться. Это предложение может быть написано аршинными буквами в отдельном окне, появляющемся при запуске, или же в виде маленького MessageBox’а, выскакивающего в самый неподходящий момент во время работы. Оно может быть мерцающим баннером вверху или внизу экрана. Но, как бы оно ни выглядело, цель его существования во всех этих случаях одна: раздражать и давить на психику незарегистрированного пользователя, дабы он, зажав в кулак свои кровные, отправился на почту и перевел их автору программы в знак благодарности за будущее избавление от назойливого окна. Однако далеко не у всех людей, созерцавших эти безыскусные творения, возникали столь теплые чувства к разработчику. И эти раздраженные пользователи дали надоедливым окнам то самое имя, под которым они и известны современному крэкеру. Называются эти окна «nag screens» (от английского «nag» - «надоедать, приставать, придираться»), и в этой главе речь как раз пойдет о способах борьбы с такими окнами. Я, увы, лично не застал рождение крэкинга под платформу Win32, но опоздал ненамного, а потому в моей коллекции имеются многие популярные руководства тех времен. Тогда первым словом едва ли не каждого крэкера была классическая команда bpx MessageBoxA – другие варианты были очень, очень редким исключением. Тридцатидвухразрядные Delphi были лишь светлой мечтой, монструозный MFC, спроектированный на базе логики пришельцев с Альфы Центавра, не пользовался особой популярностью, и потому большинство программистов тогда обходилось одним лишь Win32 API. А из всех функций WinAPI MessageBox была едва ли не самой популярной – ибо трудно было придумать более простое и универсальное средство вывести какое-нибудь нехитрое сообщение, да так, чтобы пользователь не смог это сообщение проигнорировать.Разумеется, разработчики shareware- программ не могли пройти мимо такой возможности, и первые «надоедливые экраны» были теми самыми MessageBox’ами. Мы же, в свою очередь, нещадно bpx’или эти MessageBox’ы – и выискивали точку ветвления, в которой расходились жизненные пути зарегистрированной и незарегистрированной инкарнации программы. И если когда-нибудь появится мемориал в честь крэкеров Вселенной – на нем, несомненно, огромными золотыми буквами будет выбито bpx MessageBoxA. Но все-таки я начну рассказ о борьбе с нежелательными визуальными спецэффектами в программах не с MessageBox’ов. Ибо я стараюсь следовать принципу «от простого – к сложному», а стандартные окна с сообщениями – это все-таки не самое простое из того, с чем Вы можете столкнуться. Согласитесь, чтобы удалить из программы лишний MessageBox, все-таки надо приложить некоторые усилия по обнаружению этого MessageBox’а в коде программы при помощи отладчика или дизассемблера – поэтому я начну с рассказа о таких способах удаления nag screen’ов, которые не предполагают выхода на уровень ассемблера. Вы спросите – возможно ли такое? Конечно, возможно! И, как это ни удивительно, такую возможность подарила нам операционная система Windows. Прежде чем переходить к рассмотрению методов борьбы с нежелательными окнами и конкретных примеров их применения, рассмотрим проблему баннеров и nag screen’ов, что называется, в перспективе. Я бы выделил четыре подхода к задаче ликвидации рекламных вставок в программах: · Изменение свойств объектов (окон, визуальных компонентов) таким образом, чтобы они не отображались на экране. На практике это может быть достигнуто тремя способами: выносом нежелательного объекта за пределы экрана (проще всего использовать отрицательные координаты – при любых размерах монитора объект все равно будет за пределами видимой части экрана); изменением размеров объекта (к примеру, дочерние окна нулевой длины и ширины на экране не видны); модификацией шаблонов диалогов с тем, чтобы придать нежелательным объектам свойства невидимости и неактивности. · Удаление шаблонов объектов из исполняемого файла. · Модификация кода с целью предотвратить создание или отображение нежелательного объекта. · Модификация кода таким образом, чтобы нежелательный объект самоликвидировался сразу после появления. Вот о первых двух подходах, в основном, и пойдет речь в этой главе. Ради того, чтобы упростить жизнь программистам, в ОС Windows реализована возможность интегрировать в исполняемый файл ресурсы всевозможных типов – текстовые строки, иконки, изображения в стандартных форматах и, что самое интересное, так называемые «шаблоны» (templates) диалоговых окон.
Эти шаблоны описывают внешний вид диалогов: размеры, атрибуты, находящиеся внутри диалога управляющие элементы (меню, кнопки, поля редактирования и т.п.) и надписи на этих элементах. При помощи соответствующих функций Windows API на основе этих шаблонов могут быть созданы реальные окна, внешний вид которых будет в точности соответствовать их описанию внутри шаблона. Открыв такой исполняемый файл при помощи какого-либо редактора ресурсов (наиболее известны Resource Hacker, Restorator и eXeScope), Вы можете попытаться модифицировать ресурсы программы, например, перевести все надписи на русский язык (если быть до конца точным, то у ресурсов имеется еще один атрибут – идентификатор языка, который, возможно, также потребуется модифицировать). Если Вы все сделаете правильно, и редактор ресурсов корректно сохранит изменения, после запуска программы соответствующие надписи будут русифицированы. Таким же образом Вы можете изменять расположение и свойства элементов интерфейса (например, наличие рамки и ее цвет) в диалоговых окнах. Более того, Вы можете заменить не только надписи, но и хранящиеся в ресурсах изображения, своими собственными. Нужно отметить, что ресурсы могут храниться не только внутри исполняемого файла, но и в любом другом файле формата Portable Executable. Чаще всего это делается для облегчения локализации – реализовать выбор одной из возможных ресурсных DLL гораздо проще, чем выпускать множество локализованных версий одного и того же исполняемого файла. Кроме того, ресурсные DLL – довольно удобный способ хранения «шкурок» (skin’ов) для приложений, использующих эту технологию модификации интерфейса. Вообще все ресурсы делятся на типы, среди которых есть такие, как иконки (RT_ICON), курсоры (RT_CURSOR), шаблоны диалоговых окон (RT_DIALOG) и многое другое. Для идентификации каждого конкретного ресурса среди других ресурсов того же типа используются числовые либо символьные идентификаторы. Именно на основе идентификаторов программа и различает ресурсы между собой.
Что интересно, программе совершенно нет дела до тех данных, которые прячутся за идентификаторами – поэтому, поменяв местами идентификаторы у пары однотипных ресурсов, можно получить весьма занятные эффекты: например, вместо сообщения об успешном завершении той или иной операции будет появляться сообщение об ошибке (и наоборот). Теперь немного поговорим о том, какие тайны скрываются в недрах шаблонов диалоговых окон. Прежде всего, умение обращаться с шаблонами позволит Вам полностью преобразить интерфейсы очень многих программ, причем сделать это с минимальными затратами сил. Большинство редакторов ресурсов умеют не только декодировать шаблоны в исходный текст, понятный компиляторам ресурсов (при желании Вы можете «одолжить» особенно понравившееся окно из чужой программы), но и непосредственно показывать, как этот диалог будет выглядеть на мониторе. А наиболее продвинутые редакторы позволяют даже править эти шаблоны визуальными средствами. Кто знаком с современными визуальными средствами разработки, тот, наверняка, догадался, о чем идет речь. А те, кто не догадался, могут просто взять Калькулятор из состава Windows 98 (с Калькулятором от Windows XP такой номер может не пройти – всяческие MUI могут отравить Вам радость познания), программку Resource Hacker и вдоволь поэкспериментировать над первой программой при помощи второй: например, поменять местами все кнопки в Калькуляторе. Причем настоятельно рекомендую Вам попытаться проделать эту операцию не только тасканием-бросанием кнопок а-ля Visual Basic, но и через ручную правку ресурсного скрипта (resource script). Но махинации с кнопками – это, в общем-то, мелочи, несмотря на тот могучий эффект, который производит «обработанный напильником» Калькулятор на неподготовленного пользователя. Гораздо более важно другое – а именно то, что управляющие элементы шаблона также имеют свои собственные идентификаторы, по которым программа различает управляющие элементы между собой и «общается» с ними. И эти идентификаторы также можно менять при помощи редактора ресурсов.
Попробуйте проделать над несчастным Калькулятором еще один опыт – поменяйте местами значения идентификаторов у двух кнопок, например, у единицы и семерки. Запустив Калькулятор, Вы увидите, что единица и семерка действительно поменялись местами! Кроме идентификатора Вы можете менять и другие атрибуты кнопок (более корректно называть их не атрибутами кнопок, а стилями окна) – видимость, наличие рамки, способ выравнивания текста и многое другое. И из этой возможности следуют кое-какие выводы чисто практического свойства. Довольно часто авторы shareware пользуются следующим приемом: сразу после запуска программы выводится nag screen, кнопка закрытия которого изначально неактивна и активизируется лишь спустя несколько секунд. Внутри программы это может быть реализовано следующим образом: в шаблоне, по которому создается nag screen, стиль этой кнопки изначально определен как неактивный (он называется WS_DISABLED). После создания окна запускается таймер, который через установленное время активизирует нужную кнопку, и пользователь получает возможность закрыть окно. Однако если мы уберем у кнопки стиль WS_DISABLED, пользователю больше не потребуется ждать несколько томительных секунд, чтобы получить возможность нажать на кнопку. Аналогичным же образом иногда удается включить неактивные функциональные элементы не только на nag screen’ах, но и непосредственно в самой ломаемой программе. Увы, всвязи с появлением некоторого количества программ, способных принудительно включать неактивные элементы управления, закрывать «лишние» окна и проделывать прочие столь же милые вещицы (ваш покорный слуга со своим проектом Sign 0f Misery тоже отметился на этом поприще), авторы shareware все чаще вводят различные дополнительные проверки, призванные оградить назязчивую рекламу от священного права пользователя эту рекламу не смотреть. Однако самое интересное заключено даже не в манипуляциях с идентификаторами и стилями. Куда более важным представляется вопрос – а что если просто взять и удалить из секции ресурсов «лишний» диалог? Будем рассуждать логически: создание диалоговых окон производится при помощи семейства функций CreateDialog*** (которые затем требуют принудительно отобразить окно при помощи ShowWindow) либо при помощи DialogBox*** (это семейство функций все операции по отображению окна берет на себя).
Причем для создания модальных окон (т.е. таких, которые пользователь не смог бы проигнорировать) чаще используется именно второе семейство функций. Предположим, что программа попыталась создать nag screen на основе шаблона диалога при помощи функции DialogBoxParam (или любой другой функции из семейства DialogBox***), и у нее это не получилось. Большинству разработчиков такой вариант обычно даже в голову не приходит – а потому и проверку на существование шаблона nag screen’а программа обычно не делает, а сразу переходит созданию и выводу окна на экран. Что бы Вы сделали, если бы Вас попросили создать окно на основе «никакого» шаблона? Правильно, абсолютно ничего! Вот и Windows поступает точно так же – а именно, ничего не делает. Хотя на самом деле такое «ничего» все-таки может иметь далеко идущие последствия: ни разу не выполнится, к примеру, оконная процедура выдранного с корнем диалога, что, в свою очередь может повлечь за собой труднопредсказуемые эффекты. Вспомните принцип минимального вмешательства – и ужаснитесь тому, что предлагаю Вам содеять: вырвать из программы особо ценный (с точки зрения разработчика, разумеется) ресурс, отключить «не глядя» оконную процедуру, спровоцировать передачу некорректных данных как минимум в одну функцию WinAPI… Но «суха теория, мой друг…», а потому все же посмотрим, что там у нас выросло на зеленеющем «древе жизни». В качестве наглядного пособия я возьму программу SkyMap Pro 8 Demo, которая обладает одним весьма ценным для нас качеством – наличием nag screen’а, шаблон которого упрятан глубоко в недрах секции ресурсов и имеет идентификатор 3006. В качестве орудия, при помощи которого производилась ампутация надоедливого диалога, я воспользовался программой Resource Hacker (в принципе, подошел бы и любой другой редактор ресурсов). Сама операция заняла не более 15 секунд, после чего исполняемый файл «похудел» на 4 килобайта. После того, как я произвел пробный запуск исправленной программы, оказалось, что nag screen как рукой сняло – и мне для этого не потребовались отладчики, дизассемблеры и прочая крэкерская «тяжелая артиллерия»! Видите: все далеко не так страшно, как может показаться.
В принципе, если Вы считаете, что выдирание диалога с корнем – слишком уж варварская операция, шаблон можно оставить на месте, лишь заменив его идентификатор на другой, не используемый в программе. Результат будет аналогичный – программа не сможет найти ресурс с измененным идентификатором и не создаст nag screen. Этот прием даже предпочтительнее – практика показала, что не всегда и не во всех редакторах ресурсов операция удаления проходит успешно. А ведь есть еще и третий путь избавиться от диалога путем изменения идентификатора! Ничто не мешает исправить идентификатор не в секции ресурсов, а непосредственно в исполняемом коде, в том месте, где этот идентификатор передается в функции WinAPI. Поскольку наш идентификатор имеет довольно редкое значение 3006, он как нельзя лучше подходит для поиска внутри исполняемого файла. Найдя среди всех подходящих значений то, которое отвечает за создание nag screen’а – замените его каким-нибудь другим, не указывающим ни на один диалог, например – на 12345. Когда Вы запустите исправленное приложение, оно, конечно, попытается найти и загрузить шаблон с ID=12345 – но у программы это все равно не получится. Впрочем, я считаю такой путь излишне изощренным и рассказал о нем главным образом в познавательных целях – гораздо проще забить вызов функции создания диалога и все, что с ней связано, nop’ами и получить тот же самый эффект. Успешность такого метода борьбы с «лишними» окнами, вообще, сильно зависит от внутренней логики приложения. Nag screen, к примеру, может оказаться не «пустышкой», предназначенной исключительно для раздражения пользователя, а исполнять какие-либо неочевидные функции. Например, на обработку сообщения WM_INITDIALOG в оконной процедуре nag screen’а может быть подвешена инициализация каких-либо критически важных для программы структур. В общем случае такая инициализация может происходить при возникновении практически любого события связанного с этим окном: при нажатии на какую-либо кнопку в окне, при закрытии окна, в момент поступления первого сообщения WM_PAINT – перечислять варианты можно очень долго.
И если просто удалить nag screen, это «волшебное» событие не произойдет – следовательно, не будет произведена инициализация, отчего, в итоге, программа будет работать некорректно, если вообще будет работать. На практике такая защитная техника в чистом виде встречается достаточно редко – ведь программисту, использовавшему подобный прием, придется учитывать при программировании различия в работе между полной/зарегистрированной (без nag screen’а) и урезанной/незарегистрированной версиями программы. Тем не менее, если Вы все же столкнетесь с реализацией такого алгоритма, Вам вряд ли станет легче от осознания редкости встреченной проблемы. Поэтому давайте подумаем о том, как эту проблему можно было бы решить. В теории решение довольно простое: Вам нужно найти функции, которые выполняют все нужные действия по инициализации, и вызвать их вручную. На практике Вам скорее всего понадобится добраться до цикла выборки сообщений внутри оконной процедуры и проанализировать его содержимое. Обратите также внимание на следующий факт: если Вам удалась обнаружить, что инициализация программы происходит в ответ на WM_INITDIALOG, из этого отнюдь не следует, что Вам необходимо выполнить все действия, которые программа выполняет при поступлении этого сообщения. Посмотрите на следующий пример: .IF uMsg==WM_INITDIALOG invoke GetDlgItem, hWnd, TRIALUSE_BTN invoke SetFocus,eax invoke InitProgram После того, как мы умозрительно удалили диалог, которому должно было поступать сообщение, попытка установить фокус на кнопку TRIALUSE_BTN становится совершенно бессмысленна – поскольку больше нет диалога, нет и кнопки которая в этом диалоговом окне находилась. Поэтому в нашем простейшем примере вместо вызова функций создания диалога было бы достаточно просто написать call InitProgram, а все лишние байты забить nop’ами. Однако представьте себе, что оконная процедура содержит какие-либо локальные переменные (например, вычисленное ранее число дней до истечения триального срока), используемые внутри InitProgram или же проделывает какие-либо операции с созданным диалогом – и Вы поймете, почему простейший путь не является лучшим.
Что делать в таком случае? Из каждой безвыходной ситуации есть как минимум два выхода. Можно углубиться в дебри процедуры InitProgram и проверить, не обращается ли она к каким-либо объектам, которые мы так лихо снесли вместе с диалогом и потом долго заниматься художественной штопкой машинного кода. Я сам пару раз применял такую технику и могу сказать определенно: этот процесс мне совершенно не понравился. Поэтому мы поступим хитрее – попробуем поискать другое, более простое решение. Практически в любом nag screen’е изначально заложен способ от него избавиться. Этим способом может быть клик по кнопке в диалоге, нажатие «горячей клавиши» или же истечение определенного времени с момента появления окна. Все эти случаи объединяет одно - nag screen ждет некоего события, наступление которого станет для него сигналом к исчезновению. «Приемник» этого сигнала, который и выполняет все действия по убиранию nag screen’а с экрана, чаще всего прячется во все той же оконной процедуре, о которой уже столько раз говорилось и еще не раз будет сказано. Для тех, кто не особо интересовался программированием с использованием «чистого» WinAPI, дам некоторые пояснения относительно традиционного устройства оконной процедуры. «Сердцем» оконной процедуры, несомненно, является обработка сообщений, поступающих от окна, которая на ассемблере выглядит приблизительно так: .IF uMsg==WM_DESTROY invoke PostQuitMessage,NULL .ELSEIF uMsg==WM_INITDIALOG ... .ELSEIF uMsg==WM_COMMAND mov eax,wParam .IF ax==ID1 ... .ELSEIF ax==ID2 ... .ENDIF .ELSE invoke DefWindowProc,hWnd,uMsg,wParam,lParam ret .ENDIF Не вдаваясь в подробности (которые Вы можете найти в документации и в книгах по программированию под Windows), скажу, что обычно все самое интересное - обработка нажатий на кнопки или реакция на выбор пунктов меню - скрывается в блоке, анализирующем сообщение WM_COMMAND.
Внутри этого блока обычно выполняется последовательное сравнение параметров сообщения wParam и lParam с идентификаторами и хэндлами управляющих элементов (кнопок, элементов меню, тулбаров и т.п.). Если значения хэндла и идентификатора указывают, что пользователь нажал на некий элемент - программа выполняет соответствующие действия. Допустим, что мы знаем, где располагается оконная процедура интересующего нас окна. Что мы можем сделать с нашей находкой? Самое простое – это, конечно, возможность менять местами функции управляющих элементов: если в приведенном выше примере поменять местами значения ID1 и ID2, соответственно изменятся и функции элементов с идентификаторами ID1 и ID2. Менее очевиден, но тоже достаточно прост для понимания тот факт, что можно «переключить» реакцию программы с одного сообщения на другое. К примеру, заменив в нашем примере WM_INITDIALOG на WM_PAINT, мы заставим процедуру инициализации выполняться не единожды при создании диалога, а при поступлении каждой команды на перерисовку содержимого окна. Зачем такое может понадобиться? С точки зрения среднего программиста это действо совершенно нелогично, нефункционально, и даже более того – опасно. Но крэкинг – это искусство, существующее по ту сторону обычного программирования. И именно благодаря своей «потусторонней» сущности даже во внешне бессмысленных операциях крэкер способен узреть потаенные возможности и раскрыть их к своей пользе. Итак, представим также, что кнопка ID1 закрывает nag screen (на самом деле представлять придется только Вам – у меня код этого примера сейчас перед глазами). Наша задача – убрать nag screen любыми доступными средствами. Как можно решить эту задачу? Да очень просто - базовую идею я описал парой строчек выше. Перво-наперво заменим в оконной процедуре константу WM_COMMAND на WM_PAINT. Следующее, что нам нужно – это чтобы оконная процедура реагировала на сообщение WM_PAINT как на нажатие кнопки с идентификатором ID1. Добиться этого можно, подправив условие IF ax==ID1 таким образом, чтобы оно выполнялось в любом случае, независимо от величины wParam.
Думаю, проделать такой фокус не составит никакого труда даже для самого начинающего крэкера, только-только научившегося заменять условный переход на безусловный. Если в оконной процедуре присутствует «настоящий» обработчик WM_PAINT, есть смысл при помощи безусловного перехода выполнить и его – ради соблюдения принципа минимального вмешательства. На этом нашу миссию можно считать завершенной – после внесенных исправлений nag screen будет закрываться сам собой сразу же после появления. Главная хитрость заключается в том, что как только nag screen пытается стать видимым (а Вы когда-нибудь видели невидимые nag screen’ы?), окно этого screen’а автоматически получит команду на перерисовку содержимого клиентской области - сообщение WM_PAINT. Мы же, движимые возвышенными эстетическими чувствами, протестующими против созерцания рекламных банальностей, преобразовали это безобидное сообщение в приказ немедленно убрать раздражающее окно с экрана. Все, о чем я говорил выше, относится к классическим средствам работы с окнами и диалогами Windows и к объектно-ориентированным «оберткам» для WinAPI. Однако фирма Borland внесла в такую консервативную область, как графические интерфейсы Windows-программ, заметное оживление. Я имею в виду прежде всего фирменную борландовскую разработку: Visual Component Library (она же VCL) – объектно-ориентированную библиотеку, предназначенную для создания графического интерфейса. Впервые эта библиотека появилась в Delphi 1 и с тех пор стала неотъемлемой частью всех версий Delphi и C++ Builder. В VCL широко используются такие нетрадиционные для Windows вещи, как «безоконные» управляющие элементы (non-windowed controls), по сути представляющие собой картинки на экране, и потому напрямую не подчиняющиеся функциям WinAPI. Другим новшеством, внедренным Borland, является специфический формат хранения шаблонов форм – они хранятся как данные типа RCData, причем в качестве идентификаторов используются названия классов этих форм: к примеру, шаблон формы класса TMyForm хранится в ресурсах под именем TMYFORM.
Ну и, наконец, VCL отличается от большинства библиотек аналогичной направленности обширной и довольно сложной иерархией классов. Давайте возьмем какую-нибудь программу на Delphi (я взял одну из своих разработок) и попробуем повторить эксперимент с удалением шаблона какого-нибудь диалога. Открываем файл программы в Resource Hacker, выбираем любую понравившуюся форму – и видим, что Resource Hacker нещадно глючит, пытаясь перевести шаблон из внутреннего борландовского формата в более удобочитаемое нечто. На самом деле Resource Hacker довольно успешно понимает формат Borland’овских шаблонов – просто я использовал слишком новую версию Delphi, о которой наш редактор ресурсов, увы, ничего не знает. Кстати, если взглянуть на тот же ресурс в шестнадцатиричном редакторе, Вы увидите перед собой малопонятную кучу байт, в которую вкраплены имена компонентов и их свойств, а также текстовые значения. Но мы пока не будем пытаться дешифровать шаблон, а просто удалим его. Удаляем, запускаем, пытаемся вызвать соответствующее окно – и получаем сообщение Resource <имя удаленного нами ресурса> not found. Да, это определенно не то, о чем мы мечтали – nag screen, конечно, исчез, но окошко с сообщением об ошибке смотрится ничуть не лучше. Увы, так просто от nag screen’ов в программах на Delphi не избавиться. Поскольку Resource Hacker показал себя не с лучшей стороны, внесем коррективы в наш инструментарий: возьмем eXeScope 6.41 (последняя версия на момент написания данной главы) – эта программа более-менее успешно переваривает шаблоны Delphi 7 с надписями в кодировке Unicode. Если Вы не хотите нарушать ничьих авторских прав хотя бы в процессе обучения, раздобудьте для экспериментов мою программку InqSoft Window Scanner – лицензионное соглашение дает Вам полный карт-бланш на ее ломание в учебных целях. Распакуйте ее UPX’ом, откройте полученный исполняемый файл в eXeScope и найдите шаблон формы TAboutForm. Вы увидите что-то вроде: object AboutForm: TAboutForm Left = 265 Top = 185 BorderIcons = [biSystemMenu] BorderStyle = bsSingle Caption = #1054' '#1087#1088#1086#1075#1088#1072#1084#1084#1077'...' ClientHeight = 192 ClientWidth = 318 ... end … Если Вы раньше программировали на Delphi или C++ Builder, Вы наверняка уже догадались, что это – текст типичного dfm-файла, в котором описан шаблон формы вместе со всеми ее компонентами, как визуальными, так и не очень.
Если же Вы не программировали ни на Delphi, ни на C++ Builder – Вам будет непросто разобраться в ломании программ, написанных при помощи этих средств разработки. Однако в этой главе мы будем разбирать достаточно простые вещи, для понимания которых достаточно знания английского языка, основ ООП и знакомства с любым визуальным средством разработки. В конце-концов, тот, кто умеет помещать управляющие элементы на шаблоны диалогов в RadAsm’е, вряд ли испытает какие-либо трудности в понимании того, как бросать точно такие же компоненты на формы в Delphi. Но в общем случае работает совершенно обратное правило: чтобы эффективно исследовать программу, необходимо иметь представление об инструментах, с использованием которых эта программа была написана. То есть, чтобы взломать программу, написанную на С++ Builder, нужно знать характерные особенности С++ Builder. Одной из таких характерных особенностей, к примеру, является то, что имена классов окон (понятие «класс окна» здесь используется в том же смысле, что и в документации по Windows API) в Delphi/С++ Builder начинаются с буквы T и совпадают с именами классов форм. Проще говоря, если программа создает окно как экземпляр класса TMyForm, то имя класса окна, возвращаемое WinAPI’шной функцией GetClassName, тоже будет TMyForm. Собственно, InqSoft Window Scanner в качестве объекта для экспериментов я посоветовал не случайно – эта программа, помимо прочих функций, как раз умеет определять имена классов окон. Как Вы помните, имена классов форм в программе совпадают со строковыми идентификаторами шаблонов этих форм в секции ресурсов, поэтому «подглядев» средствами WinAPI имя класса окна, Вы смело можете шерстить секцию ресурсов на предмет наличия ресурса типа RCDATA с соответствующим идентификатором, и, скорее всего, Вы такой ресурс обнаружите. Строго говоря, использование механизмов наследования в Delphi/C++ Builder позволяет создавать формы, у которых имя класса окна отличается от символьного идентификатора шаблона, но в большинстве программ эта возможность не используется.
Да и распознать форму-наследника по dfm- описанию родителя обычно бывает не так уж сложно, поэтому я не буду заострять внимание на этом достаточно специфическом случае. Итак, что нам показал eXeScope? А показал он описание формы и свойства всех ее компонентов, причем в самом что ни на есть текстовом виде. Вы наверняка уже поняли, что слово object начинает описание объекта (или компонента, если быть до конца точным), а слово end это описание завершает. И что строки вроде Left = 265, BorderIcons = [biSystemMenu] или BorderStyle = bsSingle представляют собой ни что иное, как имена свойств компонентов и значения этих свойств, как они были заданы во время разработки приложения. И, что самое приятное, Вы можете не только смотреть на эти свойства, но и редактировать их. Исправить числовые или текстовые значения – дело нехитрое, стираем старое значение, вписываем новое, сохраняем результат, и можно любоваться эффектом. Но вот если понадобится исправить свойство вроде BorderStyle – скорее всего придется заглянуть в документацию по Delphi, чтобы узнать, что такое bsSingle и какие еще значения может принимать это свойство. Пока что все довольно просто – и чем-то напоминает наши эксперименты по редактированию шаблонов диалогов в Калькуляторе. Впрочем, сказав, что изменить значение свойства – дело нехитрое, я был не совсем прав. Состояние современного инструментария таково, что если пользоваться специализированными программами (тем же eXeScope, например), то эта операция не всегда выполняется корректно. Поэтому на практике мне нередко приходилось пользоваться следующим приемом – оригинальное значение я читал при помощи eXeScope, но вот новые данные вписывал уже в шестнадцатиричном редакторе; о том, как я определял, какие байты и где надо было менять, я думаю, в очередной раз повторяться не стоит. В самых тяжелых случаях можно даже экспортировать шаблон формы в dfm-файл, а затем попытаться при помощи той же версии Delphi/C++ Builder впихнуть этот dfm-файл в тестовый проект, откомпилировать и затем перенести шаблон формы из тестового проекта на его «родное» место в подопытной программе.
При этом, разумеется, придется обеспечить совпадение версий компилятора и, если на форме имеются нестандартные компоненты, раздобыть и установить точно такие же. Как Вы догадываетесь, весь этот процесс весьма хлопотный, и такая игра вряд ли стоит свеч – я подобную «хирургическую операцию» по пересадке ресурсов проделывал лишь единожды, и то в основном в порядке эксперимента. Но вот изучение тестовых примеров перед тем, как начинать править свойства – вещь очень и очень полезная, поскольку помимо чисто практической пользы это помогает лучше понять внутреннюю логику инструментальных средств, при помощи которых создана программа. После того, как мы убедились в возможности редактировать отдельные свойства компонентов, было бы нелишне узнать, что произойдет в том случае, если удалить описание какого-либо свойства. Как ни странно, если выполнить удаление корректно – то обычно ничего страшного не случается. Хотя, справедливости ради надо отметить, что в некоторых случаях удаление свойства приводит к катастрофическим последствиям – прежде всего, когда удаляемое свойство ссылается на другой компонент. Но, к счастью, такие специфические компоненты чаще всего не имеют никакого отношения в nag screen’ам и баннерам. Причина «нечувствительности» программ к исчезновению свойств следующая: в Delphi и С++ Builder создание объекта на основе шаблона производится в два этапа. На первом этапе собственно создается объект, а все его свойства и внутренние структуры инициализируются значениями по умолчанию. И лишь на втором этапе новые значения свойств, которые хранятся в шаблоне диалога, помещаются на место значений по умолчанию. Неудивительно и то, что в eXeScope Вы видите у каждого компонента намного меньше свойств, чем видит разработчик, редактируя форму в IDE: для уменьшения объема исполняемого файла в ресурсах сохраняются не все значения свойств, а только те, которые отличаются от значений по умолчанию. Например, в приведенном выше отрывке описания формы AboutForm Вы не увидите свойство AutoSize, несмотря на то, что такое свойство у формы имеется.
Причина этого проста: значение свойства AutoSize по умолчанию было равно false, и автору (то есть мне) не пришлось его менять, поскольку оно вполне меня устраивало. Итак, теперь мы знаем, что описания свойств удалять можно. Осталось лишь разобраться, зачем это нам может понадобиться. В этот раз я отступлю от принципа «сначала - теория, потом - практика» и начну с описания небольшого эксперимента. Возьмите все тот же InqSoft Window Scanner, запустите и нажмите в нем кнопку «О программе…». Вы увидите окно с информацией о программе, в левой части которого будет находиться логотип. А теперь представьте себе, что это не безобидная картинка, которая никому не мешает, а ужасный черно-зелено-оранжевый баннер размером в пол-экрана, от которого мы хотели бы избавиться. Следующим шагом будет определение идентификатора шаблона. Запустите вторую копию Window Scanner’а и «просканируйте» окно «О программе…». В очередной раз убедившись, что класс окна носит имя TAboutForm (да, я знаю, что написал про это несчастное окно уже как минимум пять килобайт – но мы будем действовать так, как будто видим программу впервые), лезем в секцию ресурсов и ищем там шаблон формы, спрятавшийся за идентификатором TABOUTFORM. Теперь нам нужно найти в шаблоне формы описание нашего «баннера». Нетрудно догадаться, что в этой роли выступает объект Image1 (поскольку картинка на форме одна, догадаться, что Image1 и есть наш «баннер», нетрудно). Дешифрованное описание этого объекта выглядит следующим образом: object Image 1: TImage Left = 0 Top = 0 Width = 57 Height = 192 AutoSize = True Picture.Data = { 0A544A504547496D616765F5260000FFD8FFE000104A46494600010200000100 ............ 3EEBFFD9} end Теперь можно сделать то, ради чего мы и затевали все эти поиски: удалите свойство Picture.Data, запустите программу и посмотрите, что случилось с нашей формой.
А случилось именно то, чего мы и хотели добиться - логотип исчез, как будто его и не было. Сам объект, разумеется, никуда не делся – мы лишь удалили связанное с ним изображение, «подчистив» свойство Picture. Вспомните, что я говорил о двух этапах создания форм в Delphi - и Вы легко поймете, каким образом изменилась логика работы программы: стерев значение, которое было назначено свойству Picture.Data программистом, Вы не оставили программе иного выхода, как использовать значение этого свойства по умолчанию. А значением по умолчанию в нашем случае оказалось «никакое» изображение (т.е. отсутствие изображения), которое программа успешно отобразила. Более того, удалять можно не только свойства объектов, но и объекты целиком. Однако такое действо является куда более грубым вмешательством в код программы, да и ограничений в этом случае намного больше. А теперь давайте посмотрим, как простым редактированием ресурсов можно избавиться от настоящего, вполне реального баннера. Для этого нам потребуется программа Ghost Installer Free Edition. XML-редактор, входящий в состав этой программы, как раз содержит баннер. И этот баннер, кроме того, что занимает довольно много места внизу экрана и сокращает поле редактирования, еще и постоянно мерцает, раздражая пользователя. Так что если Вы активно пользуетесь бесплатной редакцией Ghost Installer’а, Вам придется либо проявлять чудеса терпеливости, либо спасти огромное количество нервных клеток, избавившись от созерцания зловредного баннера. Сначала при помощи InqSoft Window Scanner определим имя класса окна той формы, которую мы собираемся править: TfrmProjectSource. Заодно будет нелишне просканировать и сам баннер – вдруг это позволит нам узнать хотя бы тип компонента, на основе которого этот баннер был создан. А если нам удастся узнать тип компонента, это заметно облегчит задачу поиска самого компонента в дешифрованном шаблоне формы. Пробуем просканировать окно баннера, и узнаем, что на самом деле наш баннер – ни что иное, как кусок Internet Explorer’а, засунутый в программу при помощи технологии ActiveX.
Чтож, с первого захода мы получили явно недостаточно полезной информации. Поэтому попробуем копнуть глубже и просканить всю ветку дерева окон, имеющую отношение к нашему баннеру. InqSoft Window Scanner выдал следующий результат: +[00050356] { TPanel } *[00010414] { TElSplitter } +[00050370] { TPanel } +[0001040 E ] { TPanel } +[00010410] Panel 1 { TPanel } +[0043036A] {Shell Embedding} +[000103D4] {Shell DocObject View} *[00010412] {Internet Explorer_Server} Что это значит? Всего лишь то, что наш ошметок Internet Explorer’а лежит на компоненте Panel1 типа TPanel (тут программист явно поленился стереть свойство Caption у объекта Panel1, чем сильно облегчил нам задачу поиска нужного объекта). Сам объект Panel1 в свою очередь лежит на другой панели, которая лежит на панели, которая… В общем, всяких панелей там много, и разбираться в них можно долго. Поэтому я предлагаю начать наше расследование с той панели, которую мы идентифицировали однозначно. Откроем файл GIEditor.exe в Restorator 2.52 (да, Вам опять придется обновить инструментарий – практика показала, что eXeScope с редактированием шаблона в данной программе не справился, а вот Restorator сработал как нельзя лучше), выберем шаблон нашей формы и в этом шаблоне найдем следующее: object panMainBanner: TPanel Left = 0 Top = 287 Width = 553 Height = 126 Align = alBottom BevelOuter = bvNone TabOrder = 1 object Panel1: TPanel Left = 0 Top = 4 Width = 553 Height = 122 Align = alBottom BevelOuter = bvLowered Caption = 'Panel1' TabOrder = 0 object webMainBanner: TEmbeddedWB Left = 1 Top = 1 Width = 551 Height = 120 Align = alClient ... end end end Как видно из приведенного куска текста, это и есть описание нашей Panel1, баннера, который на этой панели находится (как оказалось, он представлен объектом webMainBanner типа TEmbeddedWB) и некой панели panMainBanner, на которой лежит Panel1.
Было бы логично предположить, что имя panMainBanner расшифровывается как panel for Main Banner («панель для главного баннера»), и что если ликвидировать эту панель вместе со всем ее содержимым, то назойливое мерцание баннера более не будет смущать наш взор. Сказано – сделано, переходим в режим редактирования и удаляем вышеприведенный блок текста из ресурса. Сохраняем, запускаем – и получаем GPF. Чтож, как я и предупреждал, безоглядное удаление компонентов – операция отнюдь не безобидная. Поэтому давайте поищем другой путь. В самом начале главы я выделил четыре подхода к проблеме, и мы только что испробовали тот из них, который стоит под номером два. Это, конечно, было несколько непоследовательно с моей стороны, но иначе Вы бы не смогли поэкспериментировать с удалением объектов и увидеть, к чему это иногда приводит. И вот пришло время пойти правильным путем. Действительно – это ведь всего лишь баннер, он не выпрыгивает в самый неподходящий момент на передний план, не блокирует работу с программой, даже не требует, чтобы по нему кликали. Так что нам нет принципиальной необходимости совершенно изничтожать этот баннер –достаточно просто его не видеть. В нашем случае, кстати, придется убрать не только баннер, но и панели, на которых он расположен, чтобы они не занимали место, и самый простой способ сделать это – уменьшить высоту панелей до нуля. Если быть до конца точным – нам надо спрятать лишь панель panMainBanner, поскольку компоненты webMainBanner и Panel1 находятся внутри этой панели и после «исчезновения» panMainBanner тоже не будут видимы (в общем случае этому могло бы помешать свойство AutoSize=true, но у наших подопытных компонентов это свойство равно false). В Restorator’е исправляем строчку Height=126 на Height=0, сохраняем результат и запускаем многострадальную программу. Баннера больше нет! Читая эту главу, Вы, наверное, отметили, что я меньше, чем обычно, говорил про общие подходы к проблеме рекламных окон. Действительно, чем более частные вопросы крэкинга мы рассматриваем, тем сильнее приходится «привязываться» к особенностям операционных систем и средств разработки.Но даже в этом случае «за бортом» моего повествования осталось очень многое: визуальные средства для разработки под ДОС (да, были и такие – и некоторые из них, вроде старых версий FoxPro, все еще используются), использование шаблонов диалогов в Visual Basic/VBA и т.д. Вы сами, при желании, сможете узнать об этих вещах через самостоятельные эксперименты ничуть не меньше, чем я мог бы Вам сообщить. А поскольку сама идея хранить шаблоны интерфейсов программ в унифицированном виде давно и прочно вошла в практику программирования, было бы наивным предполагать, что новые средства разработки не принесут с собой новых форматов хранения этих шаблонов. Но, я надеюсь, и в этом случае Вы сможете извлечь пользу из информации, изложенной в этой главе – разумеется, не как из руководства по конкретным инструментам, а как из источника идей. [C] CyberManiac Содержание Далее
И вновь продолжается бой…
Контролирует ценность тот, кто может ее уничтожитьФ. Херберт, «Дюна» Ну вот, все, что мы могли сделать простыми средствами, мы сделали. И теперь пришло время приступить к отладке. Да-да, эта глава будет практически полностью посвящена искусству дебагить, bpx’ить, трассировать - в общем, одному из аспектов той рутинной деятельности, которая ожидает каждого крэкера на его светлом пути к торжеству высокого искусства над коммерческими интересами. Осмелюсь предположить, что Вы имеете общее представление о том, на что похож процесс отладки программ, о точках останова, пошаговой отладке с заходом внутрь процедур и без оного и прочих столь же элементарных вещах. В интерфейсах и «горячих клавишах» отдельных представителей обширного семейства отладчиков, я думаю, Вы тоже разберетесь без особого труда – скажу лишь, что мы будем ориентироваться на работу под Windows. А наиболее достойными представителями своего племени под этой ОС на сегодня являются SoftIce(бессменный чемпион в течение многих лет – но чемпион довольно капризный) и OllyDebug (сравнительно новая, но очень качественная и совершенно бесплатная программа). Поэтому перейдем к изучению идей, которые, надеюсь, позволят Вам усовершенствовать свои умения в исследовании кодов, о полезных же особенностях конкретных отладчиков я буду упоминать тогда, когда эти полезные особенности будут нами востребованы. А поскольку методы борьбы с nagscreen’ами содержанием предыдущей главы не исчерпывается, мы будем изучать техники отладки параллельно с искусством ликвидации рекламных окон. Как я уже говорил, долгие годы первым словом, которое знаменовало рождение нового крэкера, было «bpxMessageBoxA» - то есть команда установки breakpoint’а на некую функцию WindowsAPI. И в этом, несомненно, сокрыт глубокий смысл – точки останова (они же брейкпойнты) являются одним из важнейших средств отладки, позволяющим остановить программу в нужной точке. Системные вызовы ОС являются связующим звеном между программой и операционной системой. И если во времена DOS еще существовала возможность написать программу, выполняющую некие полезные действия, выводящую результаты и при этом ни разу не обращающуюся к средствам операционной системы, то сейчас, в эпоху многозадачных операционок, блокирующих прямой доступ к абсолютному большинству аппаратных ресурсов, сделать такое практически невозможно.
Чтобы зажечь один-единственный пиксель в углу экрана, программам теперь приходится идти на поклон к операционной системе с вежливой просьбой «нарисуйте, пожалуйста, белую точку по указанным экранным координатам». С другой стороны, операционная система предлагает широкий выбор различных полезных функций – от мелочей вроде строковых операций (надо отметить, что выбор этих операций в Windowsмог бы быть и побогаче) до таких высокоуровневых функций, как работа с изображениями или уже упомянутое создание диалоговых окон по шаблонам в секции ресурсов. В общем, сделано все для удобства программиста (другое дело, что разработчики ОС иногда понимают это удобство весьма странным образом) – и разработчики этим пользуются. Пользуются этим и крэкеры. Когда какая-нибудь программка лезет в реестр, чтобы провериться на истечение триального срока – она вызывает функции API. Когда читает серийник из поля редактирования – обращается к одной из оконных функций. Даже сообщение «Зарегистрируй меня!» - и то выводится при помощи API. И вот тут-то мы их и ловим – провоцируем приложение чем-нибудь выдать свою коммерческую природу, ставим точки останова – и терпеливо ждем, пока программа не попадется в расставленные сети. Однако чтобы поймать по-настоящему хитрую дичь, ловушки нужно расставлять умело. Давайте посмотрим, каким образом в уме крэкера рождается великая идея набрать в командной строке SoftIce’а ту самую эпохальную команду – bpxMessageBoxA. Сначала крэкер изучает повадки зверя, на которого идет охота: пытается вводить невалидные серийные номера (мы ведь не в рулетку играем – так что на дурную удачу рассчитывать не приходится), вызвать заблокированные функции (некоторые программы не отключают соответствующие элементы управления) или сделать еще что-либо подобное. Конечная цель всего этого – вынудить программу проявить свою незарегистрированность каким-нибудь хорошо заметным и легко идентифицируемым способом, например – выводом стандартного окна с сообщением. Впрочем, нередко бывает и так, что специально ничего делать не надо – программа сама при запуске или в процессе работы выплюнет на экран окошко с предложением зарегистрироваться.
Допустим, что это окошко имеет специфический вид «MessageBox’а обыкновенного». Что такое «MessageBox обыкновенный», я думаю, объяснять никому не надо - единожды в жизни увидев хотя бы один MessageBox, Вы уже больше никогда не сможете забыть это ужасающее зрелище, оно будет преследовать Вас годами, лишая покоя и сна, пока Вы не решите раз и навсегда «завязать» с компьютерами. Если каким-то чудом Вам за все время работы с компьютером удалось избежать этого счастья, Вы всегда можете посмотреть на MessageBox, попытавшись обратиться из «Проводника» к дисководу, в котором нет дискеты. Посмотрели? Вот и отлично, тогда продолжим. Одна из важнейших для крэкера областей знаний есть знания о том, при помощи каких функций WindowsAPIвыполняются те или иные действия. Чтобы успешно поставить брейкпойнт на какую-либо функцию и получить от этого полезный результат, необходимо выполнение двух условий: Вы должны знать, что делает требуемая функция (сия мысль выглядит несколько банальной – но такова правда жизни) и как функция эта называется. И вот теперь Вам потребуются именно знания, одними только хорошими идеями, как мы это делали раньше, тут не обойтись. Есть два пути к обретению необходимых знаний: Вы можете либо с головой погрузиться в изучение программирования с использованием различных разделов WindowsAPI– и после этого будете способны не только влепить брейкпойнт «не глядя», но и в первом приближении оценить функции того или иного куска кода, просто посмотрев на вызываемые системные функции и передаваемые им параметры. Именно этим путем, насколько будет возможно, я и рекомендую Вам следовать – поскольку, когда Вы перейдете от обезвреживания простых защит к исследованию более защищенных программ, навыки программирования с использованием WindowsAPI Вам очень пригодятся. Однако столь обширная информация, как программирование под Windows, не уместилась бы в рамки данной статьи – поэтому мы будем учиться в условиях, «максимально приближенных к боевым». Итак: у нас есть MessageBox с сообщением, и нам нужно куда-то поставить брейкпойнт, чтобы выявить, откуда этот MessageBox появляется. Первое, что Вам надо уяснить – это то, что одно и то же действие нередко может выполняться несколькими разными функциями WinAPI.
Например, наш любимый MessageBoxможет создаваться не только функцией MessageBoxA, но и функциями MessageBoxExA и MessageBoxIndirectA. Наиболее широкими возможностями в области управления внешним видом MessageBox’а обладает функция MessageBoxIndirectA, наименьшими – собственно MessageBoxA (но зато последнюю гораздо удобнее вызывать – ведь у нее всего четыре параметра). В действительности MessageBoxA – не самостоятельная функция, а лишь удобная «обертка» для вызова MessageBoxExA. Да и сама MessageBoxExAдалеко не самодостаточна: в WindowsXP, например, она реализована через вызов функции MessageBoxTimeoutA. Что же у нас получилось: мы охотились за одной функцией создания окна с сообщением, а нашли целое гнездо функций, имеющих близкое назначение! Да, именно так – ради удобства программистов и компактности кода в WindowsAPI входит немало функций, частично дублирующих друг друга. И если Вы хотите при помощи точек останова отследить выполнение той или иной операции – Вам нужно ставить точки останова на все функции API, которые эту операцию могут выполнять. В нашем примере, если Вы поставите точку останова на MessageBox, но забудете про экзотичный MessageBoxIndirect, Вы можете просто не обнаружить точку, в которой выводится сообщение. Поэтому рекомендую Вам пользоваться следующим правилом: точки останова лучше ставить не на отдельные функции API , а на всю группу функций, выполняющих близкие по смыслу действия. Но как узнать, какие функции являются «близкими по смыслу» - спросите Вы. В этом обычно нет ничего сложного. Для начала Вам понадобится раздобыть документацию по программированию под Windows. В принципе, подойдет Win32 SDK(и даже он нужен не весь, а лишь справочные файлы по функциям WindowsAPI). Нужные файлы поставляется совместно с компиляторами под Windowsот Borlandили Microsoft, также нередко встречаются в Интернете (в том числе – частично переведенными на русский язык). Недостаток Win32 SDKв том, что он давно не обновляется – и, соответственно, не содержит информации по APIпоследних версий Windows.
Более обширным источником, несомненно, является MSDNLibrary– ежеквартально обновляемый сборник документации по программированию под Windowsс использованием компиляторов, созданных этой фирмой. Встречается MSDNлибо в составе VisualStudio, либо отдельно, и занимает, как правило, несколько CD. Если Вы не испытываете хронический дефицит места на жестком диске, я бы рекомендовал использовать именно MSDN, причем как можно более новой версии. Помимо более свежей и подробной информации о системных вызовах Windows там Вы найдете еще и подробную информацию по классам MFC (что может сильно пригодиться Вам при исследовании программ, созданных с использованием MFC). Как только у Вас на «винчестере» появится требуемая документация, поиск близких по смыслу команд для Вас перестанет представлять какую-либо сложность. Вам нужно будет лишь нажать кнопку «Group» в окне справки или заглянуть в конец справочной статьи и изучить назначение функций, имена которых стоят после слов «See also». Разумеется, можно (и нужно!) применить и другие приемы работы с документацией – просмотреть дерево словарных статей, воспользоваться контекстным поиском по заголовкам или просто выполнить поиск текста по всей справочной системе. Для тех, у кого пока нет под рукой нужной документации, я составил небольшой «поминальник» наиболее часто используемых функций WinAPI и областей, в которых эти функции используются. Однако не думайте, что Вы сможете обойтись лишь этим списком – функции WinAPI имеют не только имена, но и параметры, понимание назначения которых ничуть не менее важно, чем знание имен функций. Так что описание перечисленных функций читать все равно придется – свою же задачу я вижу в том, чтобы указать, какие разделы стоит изучить в первую очередь. Упрощенный вывод сообщений MessageBox, MessageBoxEx, MessageBoxIndirect, MessageBeep (эта функция не выводит сообщение, а только издает соответствующий звуковой сигнал) Создание и уничтожение окон CreateWindow(наиболее популярная функция создания окон), CreateWindowEx CloseWindow (функция закрытия окна), DestroyWindow Создание и уничтожение диалогов CreateDialog, CreateDialogIndirect, CreateDialogIndirectParam, CreateDialogParam (эти функции только создают диалог) DialogBox, DialogBoxIndirect, DialogBoxIndirectParam, DialogBoxParam (эта группа функций позволяет создавать модальные диалоги; управление не возвращается программе, пока диалог не будет закрыт) EndDialog, DestroyWindow Чтение и изменение текстов окон GetWindowText, GetDlgItemText (чтение текста окна или элемента диалога) GetWindowTextLength (чтение длины текста окна) GetDlgItemInt (чтение текста элемента диалога как 32-битного числа) SetWindowText, SetDlgItemText, SetDlgItemInt (установка нового текста окна или элемента диалога) Изменение видимости, позиции и прочих подобных свойств окна EnableWindow (активация/деактивация окна) ShowWindow, ShowWindowAsync (изменение видимости и состояния окна, в частности – позволяет минимизировать или наоборот развернуть окно) SetWindowPos, MoveWindow (изменение положения окна) SetWindowWord (устаревшая и практически не используемая функция), SetWindowLong – две функции, позволяющие модифицировать весьма широкий спектр параметров окна.
GetWindowWord, GetWindowLong – функции чтения этих параметров. Загрузка ресурсов LoadImage ( универсальная функция загрузки изображений, иконок и курсоров), LoadBitmap, LoadIcon, LoadCursor FindResource, FindResourceEx, LoadResource (функции загрузки ресурсов любого типа в память) Отображение изображений и текстов на экране BitBlt, StretchBlt, MaskBlt (функции копирования BITMAP’ов на экран) DrawText, TextOut, TabbedTextOut (обычный вывод текста) GrayString (редко используемая функция, выводит на экран строку со стилем надписи на неактивном управляющем элементе) Работа с файлами OpenFile (устаревшая функция), CreateFile (основная функция открытия файлов; несмотря на свое название способна открывать файлы и даже директории) ReadFile, ReadFileEx (функции чтения из файлов) WriteFile, WriteFileEx (функции записи в файл) SetFilePointer (перемещение по файлу) GetFileTime, GetFileAttributes, SetFileTime, SetFileAttributes (чтение и модификация времени создания и атрибутов файлов/директорий) SetEndOfFile (изменение размеров файла) Операции с реестром RegOpenKey, RegOpenKeyEx, RegCreateKey, RegCreateKeyEx (открытие и создание ключей реестра) RegQueryInfoKey (запрос информации о ключе, в частности – для проверки факта существования подключа) RegQueryValue,RegQueryValueEx (чтение значений из реестра) RegSetValue, RegSetValueEx (запись ключей в реестр) RegCloseKey (закрытие ключа реестра) Чтение и запись INI -файлов GetProfileSection, WriteProfileSection, GetProfileInt, GetProfileString, WriteProfileString, WriteProfileInt (функции для работы с файлом Win.ini, в настоящее время считаются устаревшими, но иногда используются) GetPrivateProfileSection, GetPrivateProfileSectionNames, WritePrivateProfileSection, GetPrivateProfileInt, GetPrivateProfileString, GetPrivateProfileStruct, WritePrivateProfileString, WritePrivateProfileInt, WritePrivateProfileStruct (функции работы с областью реестра, отведенной для хранения настроек программ, либо с произвольным INI-файлом; эта группа функций считается устаревшей) Работа с датой и временем GetSystemTime, GetLocalTime, GetSystemTimeAsFileTime (чтениетекущеговремени) SetSystemTime, SetLocalTime(установка нового времени) LocalTimeToFileTime, FileTimeToLocalTime, FileTimeToSystemTime, SystemTimeToFileTime (преобразованиеформатавремени) CompareFileTime (сравнение двух переменных, хранящих время) GetFileTime, SetFileTime (запись и чтение времени создания, последней модификации и последнего доступа к файлу) Процессы и потоки: создание и управление WinExec (устаревшая функция запуска исполняемых файлов), CreateProcess (функция, обычно используемая для запуска исполняемых файлов), ShellExecute, ShellExecuteEx (пара «альтернативных» функций для запуска приложений (применительно к исполняемым файлам) или открытия, печати и т.п.
папок и документов). ExitProcess(«стандартное» завершение процесса, эта функция способна завершить только тот процесс, внутри которого она вызвана), TerminateProcess (принудительное завершение процесса; эта функция способна «убить» любой процесс (в NT – при наличии соответствующих привилегий), что иногда используется защитами для подавления крэкерского софта) CreateThread (штатная функция создания нового потока), CreateRemoteThread (эта функция «живет» только под NT-подобными и на самом деле в защитах практически не используется. Зато очень, очень (я не забыл сказать «очень»?) широко используется самими крэкерами для внедрения в чужой процесс. Так что обойти ее вниманием в этом поминальнике было бы несправедливо) ExitThread, TerminateThread(штатное завершение и аварийное уничтожение потоков соответственно) Загрузка и выгрузка DLL LoadLibrary, LoadLibraryEx (функции загрузки динамических библиотек) LoadModule (устаревшая функция загрузки DLL) GetProcAddress (функция, возвращающая адрес функции или переменной, объявленной в экспорте DLL, по имени этой функции/переменной (разумеется, соответствующая DLL должна быть подгружена текущим процессом). Эта функция широко используется как в защитах чтобы вызов какой-либо функции не «светился» в дизассемблированном листинге, а также для приемов типа push<желаемый адрес возврата>; jmp <адрес функции, полученный через GetProcAddress>, используемых для сокрытия точки, откуда была вызвана функция) FreeLibrary (функция принудительной выгрузки DLL) Надо сказать, список этот отнюдь не полный: в нем нет, к примеру, функций выделения блоков памяти (извлечь практическую пользу из информации о распределении памяти удается довольно редко, и те, области, где это актуально, отнюдь не относятся к «основам» крэкинга) и функций работы со строками (эти функции почти всегда дублируются в коде программы ради повышения быстродействия). Нет в этом списке и специфических библиотечных функций Microsoft’овской MFC и Borland’овских VCL/RTL – если включить в список и их, «поминальник» стал бы совершенно неподъемным.
Все это мы оставим за рамками – тем более, что средства разработки не стоят на месте, и, возможно, через пару лет будут актуальны совершенно другие API. Попробуйте понять, из чего я исходил, составляя этот «поминальник» - и, поняв это, Вам станет заметно легче найти подход к ранее неизвестной системе программных интерфейсов. Я выбрал WinAPI лишь по той причине, что это – «основа основ» программирования под платформу Win32, которая в настоящее время наиболее распространена. Однако даже к изложенному выше стоит относиться с известной долей осторожности – поскольку линейка Windows 9x угасает, в ближайшем будущем вполне могут стать актуальными специфические для линейки NT системные вызовы, а Вы будете искать правды среди стандартных функций Win32 – и не найдете ее. Прецедент уже имел место – в многих крэкерских руководствах пятилетней давности рекомендовалось для гарантированного «отлова» считывания текста из окна устанавливать брейкпойнт на функцию hmemcpy. И это работало – но только под Windows 9x, поскольку в линейке NT вместо вызова этой функции для повышения скорости использовались inline-вставки. До тех пор, пока все семейство NT-подобных ограничивалось доисторической NT 3.51 и неудобоваримой NT 4, проблемы как бы не существовало. Но вот на наши десктопы пришли более симпатичные Windows 2000 и WindowsXP – и противоречие между старыми руководствами и наблюдаемой реальностью встало в полный рост. Как Вы можете видеть, имена функций являются сокращенным описанием тех действий, которые эти функции выполняют. Таким образом, если Вы предполагаете существование некой функции WinAPI и хотите поставить на нее брейкпойнт, но не знаете (или просто забыли), как она называется, Вы можете легко это узнать, если владеете принятой программистами под Win32 терминологией и английским языком (или хотя бы русско-английским словарем). Кроме того, если всем другим отладчикам Вы предпочитаете SoftIce, у Вас есть возможность воспользоваться командой EXP<текстовая_строка>, которая позволяет вывести на консоль отладчика все функции, имена которых начинаются с указанной Вами строки.
Например, команда EXP MessageBox покажет Вам все MessageBox’ы, какие только встречаются в символьной информации, импортированной из DLL (список DLLдля импортирования информации должен быть заранее прописан в инициализационном файле отладчика). Надо отметить, что команда EXP имеет несколько более широкие возможности, чем простой вывод списка функций, начинающихся с определенных символов – Вы можете также просмотреть список модулей, по которым имеется символьная информация, проверить, присутствует ли нужная функция в некотором модуле и многое другое, о чем можно прочесть в руководстве по этому отладчику. Вы наверняка заметили, что в «поминальнике» отсутствуют функции MessageBoxA и MessageBoxExA, о которых я упоминал в начале статьи, а вместо них описаны лишь MessageBox, MessageBoxEx. Разумеется, это не опечатка. Если Вы уже попробовали «на вкус» SoftIce’овую команду EXP и приложили ее к нашему измученному брейкпойнтами (то ли еще будет!) MessageBox’у, то наверняка заметили, что в списке функций, экспортируемых из USER32.DLL присутствуют MessageBoxAи MessageBoxW (а вот просто MessageBox’а нет и в помине). Откуда же взялись буквы A и W в именах функций и что они вообще значат? Расшифровка этих букв проста: A– это ANSI, W – это WIDE. А появились эти буквы после того, как в Microsoftвзяли курс на внедрение кодировки UNICODE, и возникла необходимость как-то отличать «старые» варианты функций, работающие с традиционными текстовыми строками, от «новых», использующих кодировку UNICODE. А символы в UNICODE имеют тип WСHAR длиной в 16 бит против обычных восьми – поэтому к именам функций добавили букву W, а не U, как можно было бы предположить. Вот и получается, что если функция принимает или передает строковые параметры, программист должен указать, какой из вариантов функции нужно использовать. Разумеется, если функция WinAPI не получает и не возвращает никаких символьных параметров, буквы A и W ей совершенно ни к чему. Так что единственная причина того, что в моем «поминальнике» начисто отсутствуют упоминания об ANSI- и UNICODE-вариантах одной и той же функции весьма банальна.
Если бы я подробно расписывал оба варианта имени каждой функции, мне бы пришлось набирать этот список почти в два раза дольше, а Вам – во столько же раз дольше его читать, и при этом Вы бы не получили никакой новой информации. Поэтому я и решил не упражняться в крючкотворстве, а вместо этого объяснить причины использования окончаний A и W. Надеюсь, что после моих объяснений Вы сможете подставить нужные буквы в имена функций самостоятельно. Вряд ли Вам пригодится на практике эта информация, однако стремление к истине обязывает меня сообщить Вам великую тайну. Знайте, что все-таки они существуют – я имею ввиду MessageBox’ы без буквы A или W. Ибо давным-давно, когда на Земле жили 16-разрядные динозавры Windows 3.1 и WindowsforWorkgroups, скрывавшиеся в недрах этих ОС функции ничего не ведали ни об ANSI, ни об UNICODE – а потому не нуждались в буквах A и W. И от тех древних времен в include-файлах все еще сохранились строчки вроде lstrcmp equ
После этого Вы можете ликвидировать сам nagscreen(просто забив nop’ами все, что относится к вызову функции, создающей MessageBox) а затем методом обратной трассировки в уме (о сути этого метода я подробно расскажу чуть позже) добраться от точки вызова ExitProcess до условия, по которому программа определяет, что триальный срок еще не кончился (и продолжает работать) или уже истек (и тогда вызывается ветка с ExitProcess). После этого, внеся исправления в исполняемый файл, Вы сможете организовать себе «вечный триал». Практика показала, что «отловить» появление nagscreen’ов проще всего, отслеживая при помощи точек останова следующие типы событий:
Так что в порядке эксперимента давайте немного взломаем какую-нибудь распространенную программу, например, Notepad. Это будет довольно несложный, но весьма поучительный эксперимент, демонстрирующий одну из техник ликвидации nagscreen’ов в «настоящих» программах. Итак, возьмем обыкновенный Блокнот (я взял тот, который входит в состав WindowsXP), скопируем его в надежное место, чтобы в случае чего можно было восстановить файл из резервной копии (вообще, возьмите себе за правило каждый успешный шаг отмечать резервными копиями файлов, дампами успешно модифицированных кусков и т.д. – очень неприятно бывает терять из-за ошибок результаты длительной и кропотливой работы) и запустим его. Теперь нажмите в Блокноте Ctrl-F (оно же «Правка|Найти…»), легонько стукните по клавиатуре левой пяткой (или просто наберите любой текст) и нажмите кнопку «Найти далее». Поскольку нашего «любого» текста в поле редактирования нет (потому что там вообще ничего нет), Блокнот ругнется сообщением вроде «Не удается найти "340t80543t"». Будем считать, что это и есть nagscreen, который нам предстоит ликвидировать. Очевидно, что этот «nagscreen» очень сильно похож на MessageBox – поэтому вполне логичным было бы начать поиск нашего окошка с установки брейкпойнта на функции вывода стандартных окон с сообщениями (MessageBoxA, MessageBoxW, MessageBoxExA – и далее по списку). Грузим подопытную программу в отладчик (я в качестве отладчика буду использовать OllyDebug, но те, кто предпочитает SoftIce, тоже смогут без всяких сложностей повторить этот эксперимент) и устанавливаем точки останова. В SoftIceэто делается очень просто: «BPX имя_функции» - и так много-много раз, для всех подходящих функций из «поминальника». В OllyDebugэта операция выполняется несколько сложнее, зато Вы получите весьма неожиданный, но очень приятный сюрприз. Но приятные сюрпризы будут позже, а пока щелкните правой кнопкой мыши в окне кода и вызовите всплывающее меню. В этом меню вызовите пункт «Searchfor > Name (label) incurrentmodule» - этот пункт заставляет отладчик пробежаться по загруженному модулю (в нашем случае – по EXE’шнику Блокнота) и найти в нем все ссылки на «именованные» адреса (в частности, такими адресами являются вызовы WinAPI).
Отладчик покажет окно с весьма немаленьким списком функций, на которые удалось найти ссылки в коде программы, и теперь в этом списке нам нужно найти наши МessageBox’ы. А вот и наступило время для обещанного приятного сюрприза: в списке нашлась только одна подходящая функция из «поминальника» - MessageBoxW. Так что в то время, как счастливые обладатели SoftIceсбивали в кровь руки, разбрасывая брейкпойнты на функции, которые вообще не используются в программе, пользователи OllyDebugбез всяких усилий получили информацию о том, с какого вызова APIлучше всего начать подбираться к нашему nagscreen’у. Пользователям же SoftIceчтобы получить список вызываемых программой функций APIпридется воспользоваться каким-нибудь дополнительным софтом, позволяющим просмотреть список импортируемых функций (благо таких программ немало). В SoftIce брейкпойнт на функцию ставится элементарно: BPXMessageBoxW и никаких гвоздей; в OllyDebug эта операция немного сложнее: найдя нужную строчку в списке импортированных функций, щелкните правой кнопкой мыши и в всплывшем меню выберите пункт «Followimportindisassembler». После этого отладчик мгновенно перенесет Вас в глубины одной из важнейших системных библиотек операционной системы прямо к первому байту функции MessageBoxW. После этого останется лишь нажать клавишу F2 и увидеть, как начальный адрес этой функции окрасится в красный цвет. На этом подготовительные операции закончены – пора отпустить программу на волю и начать охоту на наш «nagscreen». Попробовав опять заставить Блокнот искать «то, не знаю что», мы с радостью видим, что наша точка останова, о необходимости которой я так долго говорил, наконец-то выполнила свою задачу и «тормознула» программу, как только та попыталась выполнить функцию MessageBoxW. В окне отладчика Вы увидите что-то вроде этого: 77D70956 > 6A 00 push 0 77D70958 . FF7424 14 push dword ptr ss:[esp+14] 77D7095C . FF7424 14 push dword ptr ss:[esp+14] ; |Title = "????" 77D70960 . FF7424 14 push dword ptr ss:[esp+14] ; |Text = "????" 77D70964 .
FF7424 14 push dword ptr ss:[esp+14] ; |hOwner = 00010015 77D70968 . E8 03000000 call USER32.MessageBoxExW ; \MessageBoxExW 77D7096D . C2 1000 retn 10 И это есть ни что иное, как код функции MessageBoxWс комментариями, которые в него подставил OllyDebug (поскольку SoftIce никаких комментариев, кроме заранее определенных пользователем, подставлять не умеет, пользователям Айса придется немного напрячь воображение). При помощи кнопки F8 начинаем трассировать этот код («Ура! Мы отлаживаем ядро!») без захода в процедуры, и, дотрассировав до адреса 77D70968 с удивлением видим, что отладчик остановился, зато на экране появился наш MessageBox. А это значит, что наше исходное предположение насчет MessageBox’овой сущности «nagscreen’а» было верно. Закроем этот MessageBox и продолжим трассировать до тех пор, пока не выйдем из процедуры по команде retn 10. Пока Вы продавливаете до пола несчастную клавишу F8, я сделаю небольшое лирическое отступление. Вы наверняка уже прочувствовали, что много раз подряд нажимать F8 довольно скучно. А ведь MessageBoxW - это далеко не самая длинная процедура, нередко встречаются шедевры, в дизассемблированном виде занимающие десятки и сотни строк! И у Вас возник естественный вопрос – «неужели в таком большом и сложном отладчике нет какой-нибудь маленькой кнопки, чтобы сразу добраться до точки выхода из процедуры». Не подумайте о разработчиках плохого - они позаботились о нас, поэтому столь нужная кнопка в обеих отладчиках есть! В OllyDebug эта «кнопка» (она находится не только на панели инструментов, но и продублирована соответствующим пунктом меню) называется «Executetillreturn» и вызывается комбинацией Ctrl-F9. Заодно обратите внимание и на пункт меню «Executetillusercode»: если Вы когда-нибудь заплутаете в недрах чужой DLL и захотите одним махом выполнить весь код до того знаменательного момента, когда управление вернется в основную программу – смело жмите Alt-F9, и OllyDebug Вам поможет. В SoftIce для того, чтобы добраться до точки выхода из функции, служит команда p ret , по умолчанию «подвешенная» на клавишу F12.
Вы можете подумать, что если есть команда pret, которая заставляет программу работать до первого ret’а, то должны существовать команды pmov, ppush и т.п. Увы, это не так – разработчики SoftIceсочли, что и одного отслеживания ret’ов более чем достаточно. Но вернемся к нашему Блокноту. После того, как Вы успешно выйдете из процедуры, OllyDebug покажет примерно следующее: 01001F8C |. FF75 14 push [arg.4] ; notepad.01009800 01001F8F |. FF75 10 push [arg.3] 01001F92 |. E8 58FFFFFF call notepad.01001EEF 01001F97 |. FF75 18 push [arg.5] ; /Style = MB_OK|MB_ICONASTERISK|MB_APPLMODAL 01001F9A |. FF75 0C push [arg.2] ; |Title = "Блокнот" 01001F9D |. 56 push esi ; |Text = "Не удается найти "oierg"" 01001F9E |. FF75 08 push [arg.1] ; |hOwner = 000D063A ('Найти', ; class='#32770',parent=004C0360) 01001FA1 |. FF15 4C120001 call dword ptr ds:[<&USER32.MessageBo>; \MessageBoxW 01001FA7 |. 56 push esi ; /hMemory = 0009BCB0 01001FA8 |. 8BF8 mov edi, eax ; | 01001FAA |. FF15 F4100001 call dword ptr ds:[<&KERNEL32.LocalFr> \LocalFree Если у Вас вместо OllyDebug установлен SoftIce, Вы все равно увидите нечто подобное, но уже без подсказок о параметрах функций MessageBoxи LocalFree – SoftIce излишней дружелюбностью не страдает, а потому услужливо выискивать и расшифровывать для Вас параметры системных вызовов не станет. А жаль. Один из способов сделать так, чтобы MessageBoxне больше появлялся, заключается в забивании nop’ами «лишних» команд – а именно вызова call dword ptr ds:[<&USER32.MessageBoxW>]. Однако мало ликвидировать лишь сам CALL – нельзя забывать и о параметрах вызываемой функции. Потому что, если эти параметры не прибить тоже – они попадут в стек и так там и останутся до тех пор, пока процедуре, внутри которой эти параметры были положены на стек, не вздумается выполнить команду ret (или retn). Вот тут-то и начнется самое веселое: по-хорошему в момент выполнения команды retна вершине стека должен находиться адрес возврата, а в действительности окажется мусор, который должен был «уйти» в функцию MessageBoxW и благополучно исчезнуть в ее недрах.
В зависимости от Вашей удачливости, настроения программы и текущей фазы Луны Вы получите либо банальный GPF (почти наверняка), либо какой-нибудь экзотический спецэффект вплоть до форматирования винчестера. Насчет форматирования - это, конечно, шутка, но шутка с долей правды – представьте, что случится, если на стеке в качестве адреса возврата окажется адрес какого-нибудь осмысленного куска кода. Так что после успешной ликвидации системного вызова не поленитесь разобраться и с PUSH’ами – пусть волшебная команда XCHGEAX,EAX (более известная как NOP) станет Вашим лучшим другом. Если же Вы питаете суеверный страх перед командой NOP или Вам просто лень много-много раз набирать код 90 – просто пропишите по адресу 01001F97 команду jmp 01001FA7; результат будет тот же самый, а пальцы устанут значительно меньше. А потому, чтобы Ваш винчестер случайно не отформатировался, хорошенько запомните следующее правило: Если Вы удалили вызов какой-либо функции – проверьте, не изменила ли эта операция последовательность данных, лежащих в стеке (иными словами – не кладется ли на стек чего-нибудь лишнее или наоборот – не снимается ли с него больше, чем надо). На практике такую проверку можно выполнить следующим образом: засечь значение регистра ESPна входе в процедуру и на выходе из нее, вычислить разность между этими значениями (тут полезно помнить, что соглашения вызова PASCAL и STDCALL предусматривают удаление параметров процедуры со стека внутри процедуры, а CDECL – коррекцию стека после завершения процедуры, уже в теле программы). Если после внесения модификаций разность между значениями ESP не изменилась – значит, все сделано правильно, если изменилась – ищите ошибку в своих действиях. Разумеется, совершенно необязательно замерять разность между значениями ESP именно в начале и в конце процедуры. К примеру, если процедура, внутри которой мы «стираем» обращение к другой процедуре, вызывается при помощи обычного CALL, можно промерить значения ESP непосредственно перед выполнением этого CALL и сразу после него.
Чтобы корректно проверить, не «застряло» ли чего-нибудь в стеке, или наоборот, не удалили ли мы чего-нибудь лишнего, необходимо соблюсти следующие условия: 1. При любом ходе исполнения кода (т.е. независимо от того, какие ветки срабатывают между точками, в которых проверяется положение стека) изменение величины ESP должно быть одинаковым. 2. Вы должны быть уверены, что все операции по помещению параметров на стек, относящиеся к проверяемому вызову, находятся между точками замеров. И вот здесь-то Вас могут подстерегать довольно неприятные неожиданности. Языки высокого уровня не позволяют по-настоящему изощренно работать со стеком (компиляторы С\C++, правда, позволяет динамически зарезервировать на стеке некоторую область и даже соорудить в этой области объект – но это максимум, на что может рассчитывать программист). Даже «очень оптимизирующие» компиляторы обычно генерируют помещение параметров на стек в непосредственной близости от вызова функции, которая эти параметры принимает – поэтому найти их не так уж сложно. Но вот ассемблер… Да, ассемблер способен изменить ситуацию коренным образом. Вместо банального MyFunction (my_param) Вы вольны написать что-то вроде push my_param <сотня строчек кода, не относящегося к делу> call MyFunction А если как следует поразмыслить и поиграть с значениями регистров ESP и EBP, можно сотворить такое, что все ныне существующие дизассемблеры вывихнут свои виртуальные мозги, пытаясь разобраться, где у такой функции лежат параметры и где - локальные переменные. Правда, у подобных «антидизассемблерных» техник есть и обратная сторона – все это довольно долго пишется, тяжело отлаживается и защищает только от новичков и лентяев (надеюсь, что после прочтения этой главы Вы в число таких новичков и лентяев не попадете). Вот и получается, что замерять значения регистра ESP лучше всего на первой и на последней команде процедуры, когда вышеописанные «фокусы» со стеком еще себя не проявили, либо свое уже отработали. Раз уж зашла речь об антидизассемблерных приемах (хотя на самом деле эти приемы направлены не столько на то, чтобы сбить с толку дизассемблер, сколько на то, чтобы запутать крэкера, пытающегося осмыслить код), еще немного уклонюсь от основной темы главы и расскажу о паре хитростей, иногда использующихся для помещения параметров на стек.
Основаны эти приемы на том, что регистр ESP мало чем отличается от регистров общего назначения, а область стека – от всех прочих областей памяти. Зная, что над регистром ESP вполне возможно производить арифметические операции сложения и вычитания, а также обращаться к содержимому стека при помощи косвенной адресации командами вроде mov [ESP+8],eax, нетрудно догадаться, что команду push eax можно заменить, к примеру, последовательностью sub esp,4 mov [esp+4],eax И это только простейший способ замены одной-единственной команды… А если таких команд – много? А если загрузку значения на стек выполнять не целиком и одномоментно, а по одному байтику? А если функция, которая будет обрабатывать эти значения, вызывается не при помощи CALL, а каким-нибудь более изощренным образом? Подумайте на досуге о том, какие приемы могли бы помочь Вам преодолеть все эти сложности (кое-что я продемонстрирую Вам в следующей главе). Надеюсь, что я не слишком напугал Вас живописаниями тех ужасов, которые Вы можете встретить (а можете и не встретить) на своем пути. С функциями WinAPI, как правило, все бывает гораздо проще – число параметров в любой момент можно посмотреть в документации, «ленивые» компиляторы складируют эти параметры непосредственно перед вызовом, и удаление «лишнего» вызова не представляет собой совершенно никакой сложности – примерно как убрать MessageBox из Блокнота. А напоследок я расскажу об одном очень-очень простом способе, в некоторых случаях позволяющем найти место, в котором локализован вызов nagscreen’а. Суть способы очень проста: Вы загружаете программу и начинаете трассировать ее без захода внутрь функций, при каждом нажатии клавиши F8 запоминая текущий адрес (на практике почти всегда достаточно запоминать только адреса выполняемых call’ов). При выполнении одной из таких функции появится наш nagscreen. Снова загружаем программу, вспоминаем, каким был адрес того call’а, который вызвал появление окна и «прогоняем» программу до этого адреса в ускоренном режиме. Перейдем ко второму шагу: зная, что создание nagscreen’а сидит где-то в глубинах вызываемой функции, войдем внутрь этой функции при помощи команды трассировки с заходом в функцию.
Теперь начинаем трассировать содержимое этой функции, все так же запоминая адреса исполняемых команд. Рано или поздно мы наткнемся на очередной вызов, после которого выскочит nagscreen. Этот процесс постепенного погружения в код можно продолжать до тех пор, пока Вы не доберетесь до вызова WinAPI или другой библиотечной функции, создающей окно; не попадете в цикл обработки сообщений или ожидания закрытия окна nagscreen’а (что будет означать «перелет», но из этого результата тоже можно извлечь определенную пользу) либо не придете к выводу, что данная техника в Вашем случае неприменима. Вообще говоря, эта методика применима не только для поиска вызовов nagscreen’ов, выводимых сразу после запуска. Если Вы знакомы с численными методами решения уравнений, Вы наверняка заметили некоторую аналогию с методом Ньютона: последовательно двигаясь вглубь кода и проверяя эффекты от вызовов подпрограмм, мы констатируем «недолет» либо «перелет» и постепенно сужаем область, в которой находится интересующий нас блок команд. Метод Ньютона изначально был предназначен для поиска решения на некотором промежутке значений, и, по аналогии, предлагаемый метод также может быть использован для поиска некоей функции «на промежутке кода». В качестве границ промежутка удобно использовать «знаковые» и легко отслеживаемые события, такие, как чтение текста из управляющего элемента, обращение к ячейке памяти, получение информации из реестра или из файла и отображение результатов этих действий в виде nagscreen’а, сообщения о неверном серийном номере и т.п. Проще говоря, если Вы ввели серийный номер, нажали кнопку «ОК» и программа в ответ сказала что-то вроде «Неправильно ты, дядя Федор, серийники вводишь» - значит, проверка правильности серийного номера лежит где-то между считыванием введенного серийника и выводом сообщения. И если Вам удастся зафиксировать оба этих события при помощи брейкпойнтов, трассируя и изучая код, лежащий между этими бряками Вы с большой вероятностью обретете желаемый адрес процедуры проверки серийника.
А уж что Вы с этим адресом сделаете – зависит лишь от Ваших исходных целей и изобретательности. Предлагаемый мной способ имеет несколько существенных ограничений: во-первых, трассируемый код должен исполняться последовательно (т.е. создание окна по таймеру или какому-либо иному событию таким способом отследить не получится или, по крайней мере, будет достаточно сложно). Во-вторых, очень желательно, чтобы создание и отображение nagscreen’а происходило в главном потоке программы (если nagscreenотображается в отдельном потоке, придется сначала выискивать место создания этого потока). И, в-третьих, если Вы активно практикуете дзен-крэкинг (то бишь Ваш любимый modusoperandi – «я не знаю, как это работает, но я все равно это сломаю»), Вы можете совершить следующую ошибку: «отключая» nagscreen, можно копнуть слишком глубоко и случайно «вынести» не процедуру отображения nagscreen’а, а более универсальную процедуру отображения окна вообще, которая по сути ни в чем не виновна. Для того, чтобы Вам было понятнее, о чем идет речь, продемонстрирую идею следующим псевдокодом: ShowNagScreen proc … invoke ShowAnyWindow, nag_screen_handle … ShowNagScreen endp ShowAnyWindow proc hwnd:DWORD invoke ShowWindow, hwnd, SW_SHOWNORMAL ShowAnyWindow endp Предотвратить создание nagscreen’а в этом случае можно тремя способами:
И универсального решения этого вопроса, по-видимому, не существует (если, конечно, не считать таким решением доскональный анализ кода программы). Однако я могу предложить Вашему вниманию два подхода, которые с достаточно высокой вероятностью позволяют определить, используется ли функция исключительно для единственной цели (в частности – для отображения nagscreen’а), или же является универсальной в рамках приложения. Первый подход основывается на том, что библиотечные и просто широко используемые функции, как правило, вызываются многократно из разных областей исполняемого кода, в то время, как ссылки на узкоспециализированные процедуры (такие, как проверка серийного номера на валидность или вывод сообщения об ограничениях в программе) обычно присутствуют лишь в двух-трех экземплярах на всю программу, и во время исполнения кода приложения срабатывают считанные разы. Выполнить проверку подозрительной функции на количество вызовов можно как при помощи дизассемблера, так и прямо в процессе отладки. Поскольку большинство дизассемблеров позволяют получить список ссылок на процедуру (а также точек, в которых эти ссылки находятся), анализ при помощи дизассемблера сводится к простому поиску нужной процедуры в выходном листинге дизассемблера и визуальной оценке количества ссылок. Не могу еще раз не проагитировать Вас за использование OllyDebug: этот отладчик содержит несколько весьма удобных инструментов, скрывающихся внутри пункта меню «Findreferencesto…». В данном контексте нам, несомненно, особенно интересен подпункт «Selectedaddress», позволяющий найти все ссылки на команду под курсором. То есть, для выполнения описанной проверки Вы можете даже обойтись без дизассемблера; чтобы найти все прямые ссылки на некий адрес (например, на адрес первого байта функции), достаточно установить курсор на этот адрес и нажать Crtl-R. Если ссылок немного – значит, Вы нашли то, что искали. А вот если их количество перевалит за 5-8 штук – у Вас есть веские основания подозревать, что проверяемая функция выполняет некие общие функции, и потому Вам нужно подняться по дереву вызовов на уровень выше либо вообще искать нужный код в другой области.
Раз уж речь зашла о дереве вызовов, добавлю еще следующее: даже удостоверившись в том, что проверяемая функция A вызывается один-единственный раз из функции B, не поленитесь посмотреть, что «растет» на дереве вызовов сверху – может оказаться, что сама функция Bвызывается в программе десятки раз. В этом случае вывод очевиден – Вы слишком увлеклись «глубоким бурением». Выполнить подобную проверку при помощи отладчика ничуть не сложнее – нужно всего лишь поставить точку останова на подозрительную функцию и запустить программу «с нуля». По количеству срабатываний точки останова, а также соотнося эти срабатывания с всплыванием nagscreen’а, Вы сможете сделать вывод о том, является функция «знаковой» для защиты или же просто исполняет некие более общие функции, прямого отношения к защитным механизмам не имеющие. Другой способ выявления в общей массе библиотечных и других «общих» функций основывается на следующей особенности современных компиляторов: при сборке проекта функции размещаются в исполняемом файле в том порядке, в каком их обрабатывает компилятор, а код всевозможных библиотек помещается в начало либо в конец исполняемого файла. Кроме того, поскольку большинство проектов в настоящее время разбиты на модули, которые транслируются раздельно, получается так, что функции из одного модуля (обычно, к тому же, логически связанные, как того требуют принципы модульного программирования) располагаются по близким адресам. Как следствие, вызов функции из другого модуля или библиотеки в окне отладчика выглядит как очень длинный переход в далекие области памяти, в то время, как переходы внутри «своего» модуля являются сравнительно короткими (в смысле величины, на которую изменяется значение EIP при переходе). Вот и подошла к концу очередная глава. Надеюсь, что Вы узнали об основах дебаггинга достаточно, чтобы попробовать самостоятельно что-нибудь взломать. Раз уж мы начали с Блокнота – попробуйте сотворить что-нибудь эдакое с Калькулятором (операция деления на ноль – достаточно интересная область для экспериментов), а уж затем можно перейти от «учебных целей» и к «настоящим» задачам.Возможно, исследование «большого» приложения у Вас тоже пройдет как по маслу – но вполне может быть, что защита окажется достаточно серьезной, и в процессе анализа кода у Вас возникнут сложности. И потому следующая глава как раз и будет посвящена различным тонкостям и хитростям отладки, а также борьбе с некоторыми антиотладочными приемами. [C] CyberManiac Содержание Далее
Если бряк оказался вдруг…
Наверное, Вы уже попытались что-нибудь взломать. Может быть даже, Вам это удалось – за счет знаний и способностей к анализу, благодаря интуиции, или же в силу Вашего трудолюбия и настойчивости. Возможно также, что Вам просто очень повезло с первой в жизни программой, и защита оказалась слабее, чем в большинстве других программ. Однако тех, кто не смог с первой попытки одержать победу над мегабайтами кода, гораздо больше. Кто-то споткнулся об антиотладочные приемы, кому-то «повезло» встретиться с запакованной программой, кто-то принял близко к сердцу огромнейшие возможности, предоставляемые OllyDebug и SoftIce, и погрузился в изучение этих инструментов, отложив до времени собственно копание в коде. Некоторые отступили, не добравшись до подходящей зацепки, с которой можно было бы начать «раскручивать» защиту. Свежие ощущения, новые знания, предвкушение будущих побед – все, что знаменовало рождение крэкера, осталось в светлом прошлом, куда Вы сможете вернуться лишь в мечтах. В общем, одни радуются своей первой победе, другие – переводят дыхание и с тоской глядят на заоблачные выси, которые не удалось достичь. Если Вы попали в число «других», значит, у нас есть кое-что общее – свою первую программу я взломал далеко не с первой попытки. Надеюсь, после этих слов у Вас появился повод для оптимизма – возможно, именно Вам в будущем суждено написать свои собственные «Теоретические основы…». Однако сейчас Вас, наверняка, больше интересует другой вопрос – «почему мне не удалось сломать программу?» Причем нередко этот вопрос обретает еще более конкретную формы – «почему я ставлю брейкпойнты, а они не срабатывают?» и «как отлаживаемая программа может обнаружить мои точки останова?» И вопросы о неработающих (или «странно» работающих) брейкпойнтах – это отнюдь не повод упрекнуть в невнимательности начинающего крэкера, но основание для подробного разговора об особенностях Win32 API, тонкостях работы точек останова и антиотладочных приемах. Брейкпойнты – лучшие друзья крэкера, готовые в любой момент прийти Вам на помощь.Однако эти друзья отнюдь не всемогущи; как и живым людям, им присущи определенные слабости и врожденные особенности. И чтобы «поладить» с точками останова, нужно обладать знаниями об этих особенностях и слабостях – это, в конечном итоге, позволит Вам при помощи нехитрых приемов отлавливать весьма замысловатые ситуации и успешно обходить защитные механизмы, направленные на «вырубание» брейкпойнтов. Но прежде чем приступать к познанию столь высоких сфер, как внутреннее устройство и принципы функционирования точек останова, разберемся с куда более приземленными причинами возможной неработоспособности брейкпойнтов. Самой простой (и, к сожалению, отнюдь не самой редкой) причиной такого поведения наших верных друзей являются ошибки в коде отладчиков. Да-да, вы не ослышались, крэкерам нередко приходится тратить часы на поиски несуществующих защит именно из-за недоработок в используемом инструментарии. «SoftIce не ставит бряк на функции», «Symbol loader не останавливает программу после загрузки» и другие подобные проблемы, с которыми сталкивался едва ли не каждый пользователь этого отладчика, уже который год отравляют жизнь крэкерам. При некотором упорстве и настойчивости эти проблемы иногда удается обойти разными «шаманскими» приемами, например, использованием аппаратного брейкпойнта вместо обычного или указанием адресов в явном виде, но даже такие «танцы с бубном» не всегда оказываются эффективны против сущностей, скрывающихся по ту сторону отладчика. Никакие конкретные рекомендации тут, понятное дело, дать невозможно – программные глюки бесконечно разнообразны, и без точного знания причины с ними можно бороться разве что методом терпеливого перебора всех «обходных путей», какие только придут Вам в голову. Если Вас не прельщает сей метод – есть смысл поискать другую версию продукта (поскольку глюки, присущие одной версии программы, могут полностью отсутствовать в другой, пусть даже более старой), либо вообще подумать об обновлении инструментария. Ненамного отстают по популярности среди авторов защит всевозможные приемы определения присутствия отладчиков, от откровенно примитивной проверки наличия определенных файлов/ключей реестра (отдельные разработчики защит даже удаляют эти ключи, нимало не утруждая свой беспросветно могучий интеллект мыслями о том, что SoftIce можно приобрести легально и использовать не для взлома их поделок) до довольно изощренных антиотладочных приемов, использующих особенности конкретных отладчиков.
Примерами таких особенностей могут служить «черные ходы» в SoftIce для взаимодействия с Bounds Checker’ом или нездоровая реакция на вызов IsDebuggerPresent в OllyDebug и всех остальных отладчиках, использующих Debug API. Кстати, признаки наличия отладчика могут быть не только информационными, но и физическими: программа может «догадаться» о том, что ее ломают, по ненормально большому времени выполнения тех или иных процедур. Задумайтесь над тем, сколько времени уходит на выполнение десятка команд в «ручном режиме», когда Вы исступленно давите кнопку F8 в OllyDebug - и Вы сразу поймете, что я имею в виду. К этой же группе можно отнести использование в защитных механизмах отладочных регистров процессора: поскольку эти регистры используются отладчиком для установки брейкпойнтов, одновременная их эксплуатация программой и отладчиком невозможна, если попытаться проделать такое, либо отладчик «забудет» аппаратные точки останова, либо защитные процедуры выдадут некорректный результат со всеми вытекающими из этого последствиями. Большинство антиотладочных приемов, разумеется, давно и хорошо известны, и их описание несложно найти в руководствах по крэкингу и написанию защит. Впрочем, авторы защит на такие приемы обычно всерьез не рассчитывают, поскольку идентифицировать (а часто – и обойти) антиотладочный код в дизассемблерном листинге обычно несложно (например, если прикладная программа пытается оперировать с отладочными регистрами, это очевидный признак того, что в коде «что-то нечисто»), а некоторые крэкерские инструменты среди своих функций имеют отслеживание популярных антиотладочных приемов (примером может служить старая утилита FrogsIce, которая умела выявлять и побеждать множество защитных трюков, направленных против SoftIce). Наиболее популярным среди начинающих крэкеров, по-видимому, еще долго будет оставаться вопрос: «я поставил брейкпойнты на GetWindowText’ы и GetDlgItemText’ы, и все равно не могу поймать момент чтения серийника из окна». Действительно, формально все вроде бы сделано правильно, и все подходящие функции из «поминальника» обвешаны точками останова, как новогодняя елка – игрушками, но отладчик все равно не подает ни малейших признаков активности.
При этом все точки останова находятся на своих местах и вполне успешно срабатывают – но, увы, не по тому поводу, который Вам интересен. В общем, у неопытного кодокопателя может сложиться впечатление, что серийный номер считывается при помощи телепатии или, как минимум, весьма недокументированным способом, чтобы обнаружить который нужно иметь не меньше семи пядей во лбу. Однако в действительности никаких телепатических датчиков в Вашем компьютере нет (а если даже и есть, то вряд ли они используются для чтения текста из диалоговых окон), да и подозревать недокументированные приемы я бы тоже не торопился, поскольку существует куда более простое объяснение этого явления. В Windows с давних пор сосуществуют два различных механизма, позволяющих управлять окнами и некоторыми другими объектами. Об одном из этих механизмов – системных вызовах Windows API я уже говорил, и даже дал в предыдущей главе небольшой список наиболее употребительных функций с комментариями по поводу области их применения. Другая же сторона Windows до настоящего момента как-то оставалась в тени, за исключением эпизодических упоминаний «по поводу». Вы, наверное, уже догадались, что это за «другая сторона Windows»: я говорю о широко используемых в нашей любимой ОС сообщениях (хотя, если быть до конца точным, сообщения в том или ином виде присутствуют в большинстве современных операционных систем). Если функции WinAPI безраздельно властвуют в темном и мрачном царстве невизуальных объектов, таких, как файлы, процессы, средства синхронизации и прочее, то в «оконной» области ситуация отличается разительным образом. Сравнительно небольшой набор системных вызовов общего назначения («создать-включить-удалить окно») с лихвой компенсируется огромным разнообразием системных сообщений (в англоязычной документации – «messages»; общее число документированных сообщений уж перевалило за тысячу), подчас дублирующих функции WinAPI (например, сообщение WM_GETTEXT, которое способно читать текст окна не хуже, чем уже известная Вам функция GetWindowText).
Некоторые типы управляющих элементов, такие, как обычные или выпадающие списки, вообще не имеют полноценной «обвязки» функциями Win32 API и управляются с ними именно при помощи сообщений. Вы не сможете добавить в такой список строчку или перейти к нужной позиции, вызвав WinAPI’шную функцию с названием вроде ComboBoxAddString или ComboBoxSetPos – таких функций в системных библиотеках Windows просто нет. Зато есть сообщения CB_ADDSTRING и CB_SETCURSEL соответственно, воспользовавшись которыми, Вы легко выполните задуманное. То есть, сообщения играют роль параллельного механизма управления объектами ОС, на работу которого совершенно не влияют традиционные брейкпойнты, которые мы щедрой рукой сеяли в предыдущей главе. Поскольку сообщение – не функция, брейкпойнт на него поставить нельзя. Но очень хочется. А если очень хочется – значит, все-таки можно, хотя и не так просто, как хотелось бы. Прежде всего, Вам нужно определиться, что именно Вы хотите отловить – момент и точку отправки сообщения, либо подпрограмму обработки этого сообщения. Если Ваc интересует второй вариант и Вы являетесь поклонником SoftIce – считайте, что о Вас уже позаботилась фирма NuMega (ныне - Compuware). Встроенная в SoftIce команда BMSG как раз для этого и предназначена, но чтобы успешно ее использовать, Вам понадобится узнать хэндл окна, которому предназначено сообщение. Если нужные данные у Вас имеются – просто набирайте BMSG <хэндл_окна> <код_сообщения>, и ждите, когда «всплывет» отладчик. Разумеется, команда BMSG, как и любая другая команда установки брейкпойнтов, позволяет создавать условные точки останова, срабатывающие, например, при поступлении сообщений только с определенными значениями wParam и lParam. А что делать тем, кто пользуется другими отладчиками, в которых аналог BMSG отсутствует? Ответ на этот вопрос находится, как ни странно, именно в руководстве по SoftIce. В частности, там написано, что действие команды BMSG может быть воспроизведено установкой условного брейкпойнта на оконную процедуру, причем в качестве условия нужно указать следующее: IF (esp->8)==<имя_сообщения>; адаптация этого условия под синтаксис, принятый в конкретном отладчике, обычно сложности не представляет, хотя вместо символьного имени сообщения скорее всего придется подставить его код (коды сообщений можно найти в файлах windows.inc, winnt.h или Messages.pas – в зависимости от того, компилятор какого языка у Вас есть под рукой; те, кто не обзавелся подходящим компилятором и не планируют им обзаводиться в ближайшем будущем, могут заглянуть в файл messages.lst из состава InqSoft Window Scanner). Такой подход несколько сложнее, чем вызвать команду BMSG с нужными параметрами, но зато он дает одно немаловажное преимущество.
В предыдущем абзаце я сделал замечание насчет необходимости знать хэндл окна для успешного применения этой команды. Однако хэндл окна может быть Вам известен далеко не во всех случаях. Рассмотрим, к примеру, ситуацию, когда Вам нужно оттрассировать обработчик сообщения WM_INITDIALOG. Вы не сможете просто взять и посмотреть нужный Вам хэндл, поскольку окно только-только создано и могло еще даже не появиться на экране. Конечно, можно «заморозить» все исследуемое приложение и затем предпринять поиск среди всех окон в системе, но не кажется ли Вам, что это несколько сложнее, чем хотелось бы? Кроме того, при каждом запуске программы хэндлы меняются, что тоже отнюдь не упрощает отладку. А вот оконная процедура всегда находится на одном и том же месте (справедливости ради надо отметить, что создание «плавающей» по адресному пространству от запуска к запуску процедуры теоретически возможно, хотя я такое и не встречал). И потому адрес этой процедуры можно просто записать на бумажке, чтобы затем восстанавливать соответствующий бряк без каких-либо сложностей. Нам осталось только раздобыть адрес этой самой процедуры. Тут тоже, в принципе, ничего сложного нет – разумеется, если под рукой имеются соответствующие инструменты (к примеру, Microsoft Spy++ или все тот же InqSoft Window Scanner). Наведите «прицел» программы на интересующее Вас окно и прочитайте желанный адрес оконной процедуры собственно окна (обычно этот адрес обозначается как WndProc) или оконной процедуры, сопоставленной классу окна. Иной путь получения адреса оконной процедуры заключается в том, чтобы при помощи API-шпионов обнаружить системный вызов, при помощи которого производится регистрация класса окна (функции WinAPI RegisterClass и RegisterClassEx) либо непосредственно создание окна (список соответствующих функций я приводил в предыдущей главе). Операция эта выполняется в три этапа:
Затем загружаем программу в отладчик, ставим точку останова на этот адрес (или чуть выше – на том месте, где происходит запись в стек указателя на структуру, это уж как Вам больше понравится) и запускаем программу. Как только исполнение программы прервется на нашем брейкпойнте – находим в памяти структуру, указатель на которую передается в RegisterClass[Ex] и аккуратно переписываем содержимое поля этой структуры, содержащее адрес оконной процедуры для регистрируемого класса.
Однако Вы наверняка замечали, что правило это – не без изъяна, и не так уж редко видимая картина мира отнюдь не соответствует истинной. В крэкинге это противоречие между видимым эффектом и скрытым от невооруженного глаза назначением защитного кода может быть доведено до предела, поскольку, взламывая программу, крэкер не просто копается в машинном коде, но ведет интеллектуальный поединок с автором защиты. И со стороны противника можно ожидать всего – блефа в виде процедур-«пустышек», имитирующих защиту, сверхсложных схем, решающих простейшие задачи, ловких имитаций, призванных повести крэкера по ложному пути, и, наконец, многоуровневой системы проверок, которые не слишком сложно реализуются, но достаточно долго и нудно обезвреживаются. При написании защит редко задаются вопросами оптимальности, скорости и расхода ресурсов – все эти добродетели программирования приносятся в жертву защищенности. Я уже приводил пример того, как программа считывала дату своей установки под видом поиска плагинов в своей директории, и, разумеется, этим список возможных приемов маскировки одних действий под другие не исчерпывается. Программа eXeScope, например, в качестве сообщения об ограничении в незарегистрированной версии выдает окно, внешним видом точь-в-точь повторяющее стандартный MessageBox, но в действительности нарисованное визуальными средствами в Delphi. Отображение файла в память вместо обычного чтения в буфер – прием известный, и, тем не менее, чтение файла лицензии таким способом вполне может поставить в тупик начинающего крэкера. Я уж не говорю о таких изощренных техниках, как парсинг ini-файлов «вручную» (после чего можно очень долго возиться с точками останова на GetPrivateProfile* - разумеется, с нулевым результатом) или экспорт кусков реестра при помощи утилиты regedit во временный файл с последующим анализом этого файла (что позволяет обойтись без вызова функций работы с реестром внутри программы). Однако наиболее интересным для читателя, я думаю, будет рассмотрение причин, по которым точки останова просто исчезают из отлаживаемой программы.
Я мог бы просто назвать причину таких мистических исчезновений и изложить типовой способ решения этой проблемы, но, думаю, Вам будет гораздо интереснее понять причины, по которым «теряются» брейкпойнты. А уж теоретические знания помогут Вам самостоятельно найти подходы к решению этой проблемы еще до того, как Вы доберетесь до готовых рецептов. Очевидно, что прежде чем разбираться в защитных приемах, подавляющих точки останова, нужно сначала понять физический смысл этих самых точек, то есть узнать, что они собой представляют, как устанавливаются и по каким признакам программа может догадаться об их наличии. А поскольку точки останова – изобретение отнюдь не новое, рассказ о них следует начать с исторического экскурса в седую древность. В свое время самым популярным отладчиком для «Спектрума» был MONS (впрочем, некоторые люди, включая меня, предпочитали MON) – восьмикилобайтное порождение программистской мысли, способное загружаться в ОЗУ с любого адреса и управляемое из командной строки (прямо как SoftIce – внешнее сходство этих двух отладчиков вообще сложно не заметить). И, разумеется, MONS позволял ставить брейкпойнты – еще бы, не имея в своем арсенале такой возможности, этот отладчик вряд ли стал бы столь популярен. Но поскольку процессор Z80, на основе которого был сделан «Спектрум», никаких отладочных средств не предоставлял, авторам MONS пришлось реализовывать точки останова чисто программными средствами. Реализация эта красотой отнюдь не блистала – «установка брейкпойнта» по-Спектрумовски заключалась в подстановке в нужное место кода трехбайтной команды CALL xxxx, которая передавала исполнение в недра самого отладчика и таким образом приостанавливала исполнение пользовательского кода. Старые команды, код которых затирался брейкпойнтом, копировались в специальный буфер и дополнялись командой JP (аналог jmp из набора команд x86) для возврата к следующей команде, не испорченной CALL’ом. Исполнение в пошаговом режиме выглядело не менее оригинальным – исполняемая команда перебрасывалась в отдельный буфер, дополнялась все тем же JP, после чего отладчик передавал управление в этот буфер.
Если еще вспомнить, что в Z80 существовали недокументированные команды, которые были известны далеко не всем отладчикам (и потому могли обрабатываться некорректно), отлаживаемая программа даже при абсолютно корректной работе могла испортить код отладчика, а под сам отладчик могло элементарно не хватить свободной памяти, и потому его загружали на место «ненужных» данных – Вы поймете, что представляла собой отладка в старые добрые времена. Разработчики линейки x86 проявили больше заботы о программистах. В этой линейке процессоров вместо самодельной «затычки» в виде команды вызова подпрограммы для отладочных целей ввели отдельное прерывание с номером 3, которое вызывалось однобайтной командой (опкод команды int 3 – СС), в отличие от всех прочих прерываний, которые менее чем двумя байтами вызвать не получится. Другим полезным нововведением стала возможность исполнять код в пошаговом режиме через управление флагом трассировки (эта возможность, впрочем, мало актуальна для отладчиков пользовательского уровня под современные ОС). Однако, несмотря на такой, казалось бы, очевидный прогресс в развитии средств отладки, обыкновенные точки останова все так же, как и десятилетия назад, модифицируют исполняемый код, а потому легко обнаруживаются даже простейшими способами, например, проверкой контрольной суммы всех байтов (не говоря уже о CRC32 и использовании иных, еще более сложных и надежных хэш-функций). Самостоятельно убедиться в том, что точки останова модифицируют код, Вы можете за считанные секунды: откомпилируйте при помощи любого ассемблера следующие две строчки, возьмите OllyDebug и загрузите в него откомпилированный код. addr1: mov eax,addr1 mov al, byte ptr [eax] Если Вы просто выполните этот код в пошаговом режиме, то в регистре AL окажется число 0B8h.В этом нет ничего удивительного, B8 – это опкод команды mov eax, <число>. А теперь попробуйте поставить брейкпойнт на команду mov eax,addr1 и снова оттрассируйте этот код. После выполнения второй команды Вы увидите, что в регистре AL находится число 0CCh, хотя код в окне отладчика внешне совершенно не изменился (если, конечно, не считать изменением подсветку адреса, на который поставлен брейкпойнт).
Самое интересное, что отладчики могут «приукрашать реальность» не только в окне кода, но и при просмотре данных. Давайте проделаем еще один весьма поучительный в этом смысле эксперимент: загрузим наш пример из двух команд, поставим точку останова на первую и запишите адрес этой точки останова. Затем берем InqSoft Window Scanner и читаем байт по записанному адресу. Получаем, разумеется, 0CCh. А теперь взглянем на ту же область глазами отладчика (в OllyDebug это пункт меню Follow in dump|Selection) – и очень сильно удивляемся. Отладчик показывает нам совсем не то, что реально читается из памяти в регистр AL, a то, что должно было бы находиться по указанному адресу, если бы мы не поставили точку останова. Но и это еще не все! Посмотрите на динамические подсказки под окном кода – там-то как раз содержимое памяти отображается как надо. Вот так «умные» отладчики помогают самообманываться начинающим крэкерам: отсутствие видимых изменений в коде наводит человека, не знакомого с тайнами устройства брейкпойнтов, на мысль о том, что прерывание исполнения программы в точке останова происходит по воле неких таинственных сил, с которыми отладчик находится в телепатической связи. Хотя на самом деле «классические» точки останова – это ни что иное, как обычные memory patch’и – а потому и обнаруживаются теми же самыми способами, что и любые другие исправления в коде. Кстати, из того, что обычный (не аппаратный) брейкпойнт является ничем иным, как исправлением программы, есть одно интересное следствие. Дело в том, что SoftIce’у в общем-то без разницы, каким образом в программе появилась команда int 3 – главное, что он может на этой команде остановиться не хуже, чем на настоящем брейкпойнте. А после того, как отладчик остановится, можно внести любые поправки в содержимое регистра EIP и код программы, после чего продолжить исполнение как ни в чем не бывало (собственно, в OllyDebug тоже можно проделать такую операцию при помощи пункта New origin here из всплывающего меню). Польза от такого эрзац-брейкпойнта (после срабатывания которого, к тому же, нужно вручную восстанавливать код, который находился на месте int 3 и править EIP), на первый взгляд кажется весьма сомнительной, но она есть.
Я уже упоминал глюк в SoftIce, когда отлаживаемая программа после загрузки Symbol loader’ом начинает немедленно выполняться, хотя крэкеру хотелось бы ее в этот момент притормозить. Так вот, если в Entry point исполняемого файла воткнуть опкод 0CCh, у подопытной программы не будет ни единого шанса избегнуть процесса отладки – поскольку первой командой окажется наш int 3, принудительно активирующий отладчик. Теперь, когда мы знаем, что точки останова обнаружить можно (и даже знаем, как их можно обнаружить), можно вернуться к основному вопросу этой главы – «почему точки останова не срабатывают». В нашем случае этот вопрос можно даже конкретизировать – «какими способами подопытная программа может удалить из себя точку останова». В различных источниках мне неоднократно встречалось предложение использовать для этой цели коды коррекции ошибок, предваряя все «критичные ко взлому» участки программы вызовом функции проверки и восстановления кода. В случае изменения кода из-за появления точек останова процедура восстановления должна откорректировать «неправильные» байты. Теоретически такая схема вполне возможна, но на практике алгоритмы коррекции ошибок довольно сложны в реализации и не слишком производительны, так что народные массы эту идею не приняли. А вот более простой вариант восстановления кода из «резервной копии», расположенной в другом конце программы (или прямого вызова этой резервной процедуры вместо основной), таки имел место во времена ДОСа; впрочем я не удивлюсь, если выяснится, что такой прием до сих пор в ходу – реализация очень проста, а какой-никакой эффект все-таки имеется. На практике дело обстоит еще хуже – для удаления некоторых точек останова не нужны ни коды коррекции ошибок, ни резервные копии. И именно к таким точкам останова относятся всеми нами любимые BPX’ы на вызовы функций WinAPI (и, если смотреть шире, на вызовы практически любых функций). Поскольку «брейкпойнт на функцию» - это на самом деле всего лишь брейкпойнт на первый байт этой функции, самый простой из приемов, удаляющих точки останова, выглядит следующим образом: заранее узнать адреса нужных функций при помощи GetProcAddress, прочитать их первые байты (если речь идет о внутренних функциях программы – то просто прочитать содержимое соответствующей ячейки) и сохранить значения этих эталонных байтов.
Затем перед особо критичными вызовами нужно лишь сравнивать первые байты процедур с эталонным, и, если обнаружится несоответствие, восстанавливать их. Сам факт того, что процедура начинается с опкода 0CCh говорит о том, что на эту процедуру поставлена точка останова, что может побудить программу предпринять некоторые действия по самозащите. Если учитывать, что многие процедуры начинаются стандартной последовательностью команд push ebp; mov ebp, esp (в шестнадцатиричном редакторе эти команды выглядят как последовательность 55 8B EC), то «действия по самозащите» могут быть простой записью в первые три байта процедуры той самой стандартной последовательности 55 8B EC. После этой операции точка останова, разумеется, исчезнет. Разумеется, выявить защиту от брейкпойнтов, основанную на проверке содержимого неких адресов в памяти, не слишком сложно – нужно лишь поставить аппаратную точку останова на чтение/запись первого байта функции и посмотреть, где этот «капкан на защиту» сработает. Другой способ постановки бряков на импортированные из DLL функции основан на том, что вызов импортированной функции почти всегда выполняется не напрямую, а через «переходник». На практике вызовы через «переходник» обычно выполняются одним из двух способов. Первый способ: call <переходник_к_MyFunc> ; Вызов функции API … переходник_к_MyFunc: jmp MyFunc (этот способ вызова функций наиболее распространен; переходники вида «jmp истинный_адрес_функции» обычно собраны в конце программы) Второй способ: mov edi, dword ptr ds:[элемент_в_таблице] call edi … элемент_в_таблице: dd <истинный_адрес_функции_MyFunc> (данная техника вызова функций обычно встречается в продуктах Microsoft) Идея заключается в том, что в первом случае точку останова можно поставить не на первый байт функции, а на переходник, через который вызывается эта функция. В первом случае это будет обычный BPX на адрес команды jmp MyFunc, во втором случае придется прибегнуть к аппаратной точке останова на чтение двойного слова по адресу «элемент_в_таблице».
Поскольку это не будут брейкпойнты на функцию в прямом смысле слова, этот метод имеет одно существенное ограничение: если нужная функция вызывается не через «переходник», а непосредственно по значению ее адреса (получаемому, например, при помощи GetProcAddress), то такой брейкпойнт, понятное дело, не сработает. Разумеется, также существует возможность, что программа попытается проверить целостность «переходников», но как такие попытки обнаруживать, Вы уже знаете. Если некая процедура вызывается внутри программы стандартным образом, то большинство современных компиляторов генерирует последовательность команд push для помещения параметров функции на стек, собственно переход к процедуре выполняется при помощи команды CALL, а после того, как функция отработает, управление возвращается на команду, следующую за CALL. Однако если программист имеет достаточно высокую квалификацию, он может внести заметное разнообразие в эту картину при помощи «ручного» вызова функций средствами ассемблера. Хотя великий Intel завещал нам вызывать процедуры и функции при помощи специально для этого придуманной команды CALL, отдельным гражданам закон не писан (надо отметить, в число этих граждан входят не только авторы защит, но и любители предельной оптимизации, а также фанаты нетрадиционного программирования). И вот эти странные граждане сочинили несколько имитаций несчастной команды CALL, и эти имитации давно и прочно вошли в арсенал разработчиков защит. Из универсальных нетрадиционных средств вызова подпрограмм прежде всего нужно назвать следующие: push <адрес возврата> jmp <адрес процедуры> или push <адрес возврата> push <адрес процедуры> ret Более сложные техники неявной передачи управления основаны на умышленном создании и обработке исключительных ситуаций, вызове прерываний и эксплуатации особенностей конкретных ОС. Эти техники сами по себе представляют весьма значительный интерес – с точки зрения как крэкера, так и программиста, однако их количество практически бесконечно, а сложность нередко выходит далеко за пределами «основ».
Чтобы Вы имели представление о том, насколько обширна эта тема, сообщу, что любые функции WinAPI (да и вообще любого другого API), в параметрах которых фигурирует callback-функция, могут служить инструментом неочевидного вызова пользовательского кода. Вместо рассмотрения всего этого бесконечного разнообразия возможных приемов (большинство из которых Вы, возможно, вообще никогда не встретите) мы углубимся в исследование возможностей приведенной выше пары базовых «заменителей CALL», понимание которых в итоге дает ключ к «раскалыванию» многих других способов неочевидного вызова процедур. Прежде всего следует отметить, что помещение на стек адреса возврата в этих методах отделено от собственно вызова процедуры, что позволяет вклинить между двумя этими действиями практически любой код, например, кусок вызываемой процедуры – и, соответственно, вызывать эту процедуру не с первого байта, а «с середины». Например, вот таким образом: push <адрес возврата> push ebp mov ebp, esp jmp MyProc+3 ; (1) … MyProc: push ebp mov ebp,esp … ; При вызове процедуры в точке (1) будет выполнен переход в эту точку Как видите, хотя приведенный кусок кода по функциональности полностью аналогичен тривиальному call MyProc, явным образом процедура MyProc нигде не вызывается. Причем при помощи макросов можно добиться того, что все вызовы процедуры MyProc в программе будут выглядеть именно таким образом! Так что, сколько бы Вы ни ставили брейкпойнтов на адрес MyProc, ни один из них никогда не сработает – по той простой причине, что управление на этот адрес просто никогда не передается, хотя все внешние признаки могут говорить о том, что процедура MyProc успешно отработала. Впрочем, обмануть такой защитный прием обычно не составляет никакой сложности – нужно лишь ставить точку останова не на первую команду в процедуре, а где-нибудь подальше, например, после третьей (можно даже на команду выхода из процедуры, но при этом следует помнить, что процедура может содержать несколько точек выхода) или вообще в какой-нибудь подпрограмме, вызываемой этой процедурой (вторым способом я нередко пользуюсь, когда ставлю точки останова на функции WinAPI). Другая проблема, которую порождают нетрадиционные способы вызова процедур, заключается в том, что адрес возврата может быть совершенно любым, а не только адресом команды, следующей за командой вызова.
Этот прием встречается довольно часто, когда автор защиты хочет скрыть адрес какой-либо функции, к примеру, проверки корректности введенного серийного номера. Рассуждая логически, он понимает, что необходимо максимально осложнить крэкеру нахождение связи между появлением сообщения о неверном серийнике и процедурой, этот серийник проверяющей. А поскольку традиционным приемом поиска такой процедуры является BPX MessageBox с последующим наблюдением, куда вернется программа из MessageBox’а – программист делает вывод, что хорошо бы сделать так, чтобы программа вернулась «не туда», т.е. как можно дальше от процедуры проверки серийника. В этом случае даже поставив брейкпойнт «куда надо», мы не узнаем, по какому поводу был произведен вызов функции – на стеке будет лежать совершенно другой адрес возврата. И особенно неприятно для начинающего крэкера, когда в качестве адреса возврата оказывается что-нибудь вроде адреса функции ExitProcess. В общем случае «победить» такой прием можно либо через долгую медитацию с массированным применением шестнадцатиричного редактора/дизассемблера для поиска всех точек, в которых программа явно или неявно оперирует адресами нужных функций WinAPI, либо через поиск «модифицированным методом Ньютона», описанным в предыдущей главе, либо применив средства трассировки кода, имеющиеся в SoftIce или OllyDebug. Собственно, трассировка в таких случаях является орудием, по свойствам приближающимся к термоядерной бомбе: редкий код способен выдержать такой удар, но чтобы получить желаемый эффект, требуется весьма серьезное техническое обеспечение (процесс трассировки требует немалых объемов памяти и достаточно быстрого процессора) и грамотный выбор области применения. Так что, прежде чем пускать в ход «тяжелое вооружение», есть смысл подумать о решении проблемы более простыми средствами. Одним из таких более простых средств является исследование содержимого стека на предмет «застрявших» в нем полезных данных и адресов. Суть метода заключается в следующем: любой кусок кода в программе существуют не сами по себе, но находятся во взаимодействии с чем-то, и каждая из процедур может быть как вызываемой (из процедуры более высокого уровня), так и вызывающей «подчиненные» ей процедуры.
И даже когда программист скрыл точку вызова конкретной процедуры (что, как я продемонстрировал, не так уж сложно), то спрятать от пытливого взора следы, оставленные выше- и нижележащими процедурами, ему могло и не удаться. А если «могло не удаться» - есть смысл попробовать отыскать эти следы. По традиции, для начала разберемся, что эти следы из себя представляют. Представьте себе следующую широко распространенную ситуацию: A=>B=>C, где «A=>B» расшифровывается как «процедура A вызывает процедуру B». При вызове процедуры B стандартными средствами, т.е. командой CALL, в момент начала исполнения процедуры B на вершине стека будет лежать адрес возврата из процедуры B в процедуру A. Аналогичный процесс происходит при вызове процедуры C процедурой B. Получается, что если мы поставим брейкпойнт на точку входа в процедуру C, мы в этот момент сможем наблюдать в стеке следующую картину (вершина стека - вверху):
Теоретические основы крэкинга
Что тебя смутит – то ложь Е. Летов За пять лет более или менее активного занятия крэкингом (а именно столько времени прошло с того знаменательного момента, как мне удалось впервые в жизни взломать программу) мне удалось выработать набор методов, позволяющих сравнительно быстро найти подход к большинству программ. Возьму на себя смелость утверждать, что именно эта система наиболее доступна для начинающих, поскольку она базируется не на раскрытии алгоритма работы защиты в целом, а на анализе сравнительно небольших участков кода и установлении взаимосвязи между ними. Несмотря на то, что крэкеры, в большинстве своем, работают под одной хорошо известной операционной системой и пользуются сходными инструментами, я буду делать упор не на описание этих инструментов и правил работы с ними, а на универсальные идеи (хотя в качестве примеров я, понятное дело, буду приводить то, что наиболее доступно для самостоятельных экспериментов). Думаю, что Вы сами определитесь, какие инструменты Вам больше по душе. Предлагаемую мной систему не следует воспринимать как единственно правильную, это не «истина в последней инстанции», но именно эта система помогла мне взломать десятки программ, так что ее эффективность неоднократно проверена и подтверждена длительной практикой. Моя точка зрения такова: одна лишь практическая эффективность может служить критерием того, какие идеи и технические приемы могут и должны применяться крэкером в его «профессиональной» деятельности. Именно поэтому я настоятельно рекомендую отрешиться от любых утверждений о «некрасивости» тех или иных приемов борьбы с защитами и ориентироваться лишь на достижение конечной цели – независимо от того, является ли этой целью раскрытие алгоритма работы какого-либо куска кода, или же простое снятие триальных ограничений в программе. Так или иначе, статей от том, «что делать», то есть как взломать конкретную программу или тип защиты, во много раз больше, чем руководств «как и почему надо делать именно это». Образно говоря, статьи о том, «что делать» - это уровень начальной школы, «почему был выбран именно такой прием» - уровень выпускных классов.Но ведь есть еще и высшая школа - изучение идей, которые не привязаны к каким-либо программам и типам защит, но могут быть адаптированы для решения любой конкретной задачи. Выше – только «научная работа», то есть область чистого творчества, генерация оригинальных идей, и никакой «учебник» в этой области принципиально невозможен. По моему глубокому убеждению, основная проблема, возникающая у начинающих крэкеров, заключается в огромном количестве пособий уровня «делай, как я», совершенно не объясняющих, почему автор пособия поступил именно так. В результате начинающий крэкер «на ура» ломает новые версии одной и той же программы, но теряется перед подобной же защитой, но реализованной слегка иначе. Разумеется, существуют и весьма качественные «учебники», предлагающие именно систему, а не только набор технических приемов (те же + ORC Cracking Tutorialz или руководство от the Keyboard Caper’а) – но абсолютное большинство их них написаны на английском языке. Поскольку каждый человек имеет право получать необходимую информацию на своем родном языке (а для меня, как и для многих других, родным языком является русский), рано или поздно должны были появиться русскоязычные тексты, систематизирующие опыт крэкинга. Именно такой материал я и старался написать, а насколько хорошо это у меня получилось - решать вам. Содержание Далее
Инновации: Менеджмент - Моделирование - Софт
- Инновационный менеджмент
- Разработка программного обеспечения
- Россия и инновации
- Управление инновационным менеджментом
- Моделирование
- Финансовое моделирование
- Системы моделирования
- Виды моделирования
- Практическое моделирование
- Пакет Mechanical Desktop
- Моделирование программ
- Софт для моделирования
- Пакет Simulink
- Моделирование в IBM
- MSC Nastran Моделирование -
- AutoCAD
- Unigraphics
- P-CAD