Теоретические основы крэкинга
За пять лет более или менее активного занятия крэкингом (а именно столько времени прошло с того знаменательного момента, как мне удалось впервые в жизни взломать программу) мне удалось выработать набор методов, позволяющих сравнительно быстро найти подход к большинству программ. Возьму на себя смелость утверждать, что именно эта система наиболее доступна для начинающих, поскольку она базируется не на раскрытии алгоритма работы защиты в целом, а на анализе сравнительно небольших участков кода и установлении взаимосвязи между ними.
Несмотря на то, что крэкеры, в большинстве своем, работают под одной хорошо известной операционной системой и пользуются сходными инструментами, я буду делать упор не на описание этих инструментов и правил работы с ними, а на универсальные идеи (хотя в качестве примеров я, понятное дело, буду приводить то, что наиболее доступно для самостоятельных экспериментов). Думаю, что Вы сами определитесь, какие инструменты Вам больше по душе.
Предлагаемую мной систему не следует воспринимать как единственно правильную, это не «истина в последней инстанции», но именно эта система помогла мне взломать десятки программ, так что ее эффективность неоднократно проверена и подтверждена длительной практикой. Моя точка зрения такова: одна лишь практическая эффективность может служить критерием того, какие идеи и технические приемы могут и должны применяться крэкером в его «профессиональной» деятельности. Именно поэтому я настоятельно рекомендую отрешиться от любых утверждений о «некрасивости» тех или иных приемов борьбы с защитами и ориентироваться лишь на достижение конечной цели – независимо от того, является ли этой целью раскрытие алгоритма работы какого-либо куска кода, или же простое снятие триальных ограничений в программе.
Так или иначе, статей от том, «что делать», то есть как взломать конкретную программу или тип защиты, во много раз больше, чем руководств «как и почему надо делать именно это». Образно говоря, статьи о том, «что делать» - это уровень начальной школы, «почему был выбран именно такой прием» - уровень выпускных классов.
Но ведь есть еще и высшая школа - изучение идей, которые не привязаны к каким-либо программам и типам защит, но могут быть адаптированы для решения любой конкретной задачи. Выше – только «научная работа», то есть область чистого творчества, генерация оригинальных идей, и никакой «учебник» в этой области принципиально невозможен. По моему глубокому убеждению, основная проблема, возникающая у начинающих крэкеров, заключается в огромном количестве пособий уровня «делай, как я», совершенно не объясняющих, почему автор пособия поступил именно так. В результате начинающий крэкер «на ура» ломает новые версии одной и той же программы, но теряется перед подобной же защитой, но реализованной слегка иначе. Разумеется, существуют и весьма качественные «учебники», предлагающие именно систему, а не только набор технических приемов (те же +ORC Cracking Tutorialz или руководство от the Keyboard Caper’а) – но абсолютное большинство их них написаны на английском языке. Поскольку каждый человек имеет право получать необходимую информацию на своем родном языке (а для меня, как и для многих других, родным языком является русский), рано или поздно должны были появиться русскоязычные тексты, систематизирующие опыт крэкинга. Именно такой материал я и старался написать, а насколько хорошо это у меня получилось - решать вам.
Глава 0.
Дзен-крэкинг. Теория.
Основной идеей дзен-крэкинга (именно это название широко используется на сайте 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 может быть всего лишь еще одной, еще не выявленной уловкой создателя защиты.
Глава -1.
Орудия крэкера.
Ваше слово, товарищ «Маузер»
В. Маяковский
Осмелюсь предположить, что Вы читаете этот текст не из праздного интереса или ради абстрактного «знания», а хотите научиться применять эти знания на практике.
То есть ломать программы. И хотя это пособие носит название «Теоретические основы…», Вы не замедлите применить эти «основы» на практике. А поскольку крэкинг отнюдь не ограничивается теорией, Вам потребуются «рычаги», при помощи которых Вы сможете перевернуть код. И именно об этих «рычагах» и пойдет речь в данной главе. Раз обучение крэкингу требует постоянной и разнообразной практики, было бы логично начать с перечисления того, что Вам потребуется для «практических занятий». Но, с другой стороны, вряд ли Вы сможете выбрать наилучшие инструменты, не зная хотя бы в общих чертах особенностей Вашей будущей деятельности. И именно поэтому глава носит номер «минус один», но следует за «нулевой» главой, в которой я попытался объяснить, чем Вы будете заниматься и какие трудности могут Вас ожидать.
Инструменты – это материальная основа крэкинга, без инструментов в крэкинге – никуда, но сами по себе инструменты – не более чем набор байтов, и лишь человеческая мысль оживляет их, заставляя творить чудеса. Для использования этих программ, как правило, требуются соответствующие знания. Насколько будет Вам полезен лучший отладчик в мире, если Вы не знаете, что означают те хитрые цифры и буквы, которые он показывает на экране? Поэтому прежде чем задать программе вопрос, Вы должны быть уверены, что сможете понять ответ.
Но, тем не менее, от качества этих инструментов будет во многом зависеть скорость и эффективность Ваших действий. Ваш инструментарий должен постоянно обновляться; не надейтесь, что софт десятилетней давности сможет чем-то Вам помочь – в области высоких технологий радикальные изменения могут произойти за считанные дни. Программы совершенствуются, наращивают мощность, обрастают новыми полезными и бесполезными функциями; защиты также не стоят на месте – и Вам нужно быть в курсе этих процессов, своевременно обновляя свой арсенал.
К вашему счастью, инструментов, пригодных для использования в крэкинге, не так уж мало, и проблема состоит не в том, чтобы их раздобыть, а в том, чтобы отобрать из них наилучшие, изучить их возможности и определить для себя, в какой ситуации тот или иной инструмент лучше всего применить.
Поясню эту мысль на примере: на данный момент наиболее мощным из дизассемблеров является IDA Pro, которая способен не просто дизассемблировать код, но и находить в нем вызовы стандартных функций тех или иных компиляторов. Однако если мне необходимо покопаться в программе, написанной на Delphi версии выше третьей, я наверняка не буду использовать IDA Pro, предпочтя ему Delphi Decompiler. Почему? Во-первых, скорость работы IDA Pro и DeDe различается в десятки раз в пользу последнего; используя DeDe, я скорее всего получу желаемый результат раньше, чем закончилось бы дизассемблирование в IDA Pro. Во-вторых, DeDe позволяет анализировать работающие процессы «на лету», что позволяет анализировать сжатые программы, не отвлекаясь на распаковку, восстановление таблицы импорта и прочие вспомогательные действия. Не углубляясь в дальнейшее перечисление достоинств DeDe, начисто отсутствующих в IDA, скажу, что в большинстве случаев специализированная программа позволяет решать свой «родной» класс задач значительно эффективнее, чем программы «общего назначения», ориентированные на ручную работу. Конечно, никто не запретит вам распаковывать программы, вручную копируя секции из памяти в файл, но не проще ли воспользоваться дампером?
Наверняка найдутся те, кто возразит, что широкое использование готовых утилит якобы мешает самостоятельному мышлению, чрезмерно упрощает процесс взлома и вообще «настоящие хакеры дизассемблируют в уме». Так вот, мое принципиальное мнение по этому поводу такое: используйте все программы, какие только сочтете нужными, если это поможет вам добиться желаемого. В конце-концов, цель крэкера обычно состоит в получении работающей программы, а никак не в тренировке памяти или демонстрации собственной крутизны. А всем «настоящим хакерам» посоветуйте дизассемблировать в уме winword.exe из состава самого последнего MS Office, и до тех пор, пока они это не сделают, не беспокоить вас древними суевериями. Разумеется, рано или поздно Вы перейдете от использования чужих программ к написанию собственных патчеров, распаковщиков, дамперов и прочих утилит – но такой переход должен быть продиктован насущной необходимостью, а не обезьяним «чтобы быть не хуже других».
В конце-концов, большинство программ было написано именно для того, чтобы люди ими пользовались.
Итак, какие инструменты и для чего нам потребуются?
Интерпретатор может как поставляться отдельно (Java), так и быть «прикрепленным» к самой программе (хотя формально не интерпретатор прикрепляется к программе, а программа к интерпретатору. Примером может служить Visual Basic при компиляции в p-code). Возможны и более экзотические варианты, например, применяемый в Форте «шитый код»; компиляция программы в цепочку команд push\call или преобразование текста программы в такую цепочку непосредственно при запуске этой программы. Интерпретаторами являются практически все инсталляторы (в их основе лежит интерпретатор инсталляционного скрипта, хотя сам процесс создания такого скрипта может быть скрыт при помощи использования визуальных средств). Да и «обычные» компилирующие языки могут создавать код, прямой анализ которого весьма затруднителен. Для анализа таких программ используются специализированные утилиты, переводящие код, понятный лишь интерпретатору, в форму, более удобную для понимания человеком. Также некоторые декомпиляторы могут извлекать информацию об элементах интерфейса, созданных визуальными средствами. В любом случае, не следует ожидать от декомпиляторов восстановления исходного текста программы; если декомпилированная программа успешно компилируется и сохраняет работоспособность – это исключение, а не правило.
Защитные системы нередко пытаются противодействовать получению работоспособного дампа при помощи манипуляций с сегментами и таблицами импорта-экспорта. В этих случаях приходится PE-реконструкторы, т.е. утилиты, обнаруживающие в дампе некорректные ссылки на функции и пытающиеся их восстановить. Многие продвинутые дамперы и распаковщики имеют встроенные средства восстановления секций импорта.
Более того, некоторые из утилит, которые могут быть полезны для крэкера, создавались для совершенно иных целей (хорошим примером может служить программа GameWizard32, которая вообще-то была предназначена, чтобы мухлевать в компьютерных играх, но оказалась полезна при вскрытии программы с ограничением на максимальное число вводимых записей). Поэтому еще раз обращу Ваше внимание: важно не только качество инструмента, но и умение творчески и нетривиально его применить.
Глава 1.
Почти начинаем ломать.
Итак, допустим, что у нас есть программа и в ней содержится вредоносный код (далее - «защита»), который нужно обезвредить. Инсталляционный файл программки тихо лежат на нашем винчестере, ожидая того знаменательного момента, когда мы его запустим. Мне вполне понятно Ваше желание немедленно установить эту программу и вступить в битву со злобным и коварным врагом, но охладите на несколько минут свой пыл, и послушайте мой рассказ о процессе инсталляции программ, и о том, чем этот процесс может кончиться.
Итак, многие программы в настоящее время поставляются в виде инсталляционного пакета. Для установки программы, как правило, требуется либо запустить один из файлов пакета (это, в частности, отлично всем известные Setup.exe), либо открыть при помощи другой заранее установленной программы (к примеру, файлы с расширением MSI, созданные Microsoft’овским инсталлятором или RPM-пакеты в Linux). В инсталляционные пакеты кроме самих файлов, которые требуется установить на машину пользователя, содержится также описание сценария инсталляции в том или ином виде (назовем это описание сценария для простоты «инсталляционным скриптом», тем более, что чаще всего так оно и есть). Разумеется, инсталлятор может быть и обычной программой, написанной для установки конкретного приложения, но написание собственного инсталлятора – дело достаточно трудоемкое, и потому на практике такие инсталляторы встречаются весьма редко.
Инсталляционный скрипт описывает, в каком режиме устанавливается тот или иной файл (добавление, замена, замена с предварительной проверкой версии и т.п.), какие данные необходимо внести в реестр или файлы конфигурации, какие программы запускаются до, в процессе и после инсталляции, а также многое другое.
В это «многое другое» могут входить и такие безусловно интересные вещи, как проверка серийных номеров, создание записей в реестре, а также распаковка и запуск программ. О встроенных в инсталляционный скрипт серийных номерах мы поговорим позже. А пока попробуем поразмышлять о том, может заключаться опасность создания ключей в реестре или запуска каких-либо программ.
Если Вы взламываете какую-либо программу, оснащенную ограничением на время использования или число запусков, один из «корней зла» может гнездиться именно в инсталляционном скрипте. Представьте себе такую ситуацию: программа хранит дату первого запуска и/или какую-либо иную информацию, необходимую для проверки на истечение срока пробного использования, в реестре. Разумеется, информация закодирована, предприняты меры, чтобы защиту не могло обмануть «подкручивание» системной даты, возможно даже соответствующий ключ реестра хорошо замаскирован (Вам ничего не напоминает мое описание? Да это же ASProtect – ломаный-переломаный, но, как ни странно, все еще популярный). Но, тем не менее, одна лазейка все-таки осталась – если триальный ключ в реестре отсутствует, защита считает, что раньше программа не запускалась. Поэтому защиту можно обмануть, просто удалив из реестра лишние ключики. А теперь представьте, что триальный ключ создается в процессе инсталляции, и если он отсутствует, программа не запускается вообще! Если Вы ставите целью взлома ликвидацию триальных ограничений, Вам могут потребоваться весьма значительные усилия, чтобы отыскать этот маленький, но зловредный ключик в огромном реестре еще более огромной Windows. Как вам нравится такая перспектива? Если встроенных средств инсталлятора оказывается недостаточно для создания триальной «метки», это может быть реализовано при помощи небольшого исполняемого файла, который распаковывается в процессе инсталляции, запускается, делает свое черное дело и сразу после этого удаляется. Упрощенный вариант этого приема может выглядеть как автоматический запуск приложения после окончания инсталляции, чтобы защита смогла создать триальные «метки» на компьютере пользователя.
В качестве метки, на основе которой программа будет проверять ограничения по времени или числу запусков, также может использоваться какой-либо файл, создаваемый в процессе инсталляции, особенно если этот файл запрятан где-нибудь глубоко в системных директориях, но при этом активно используется программой. Я встречал защиту, основанную на подобном принципе: дата создания одной из DLL использовалась для отсчета времени с момента установки, а сама DLL была спрятана в системной директории Windows и динамически подгружалась основной программой.
Какие средства мы можем применить, чтобы обнаружить и обезвредить эти и другие подобные приемы? Наиболее радикальным средством, разумеется, является декомпиляция инсталляционного скрипта – в этом случае мы получаем практически полную информацию о том, что происходит в процессе установки программы, а в некоторых случаях даже можем повлиять на этот процесс, внеся исправления в инсталлятор. Разобрать инсталляционный скрипт «по косточкам» - задача не самая простая, да и не всегда это необходимо, поэтому на практике чаще пользуются другим типовым приемом, позволяющим обнаружить произошедшие изменения. Этот прием заключается в использовании утилит мониторинга, делающих «снимки» системы (реестра, размеров и дат создания и модификации файлов) до и после установки и затем анализирующих различия между снимками. Подробный журнал изменений, выдаваемый такими программами, позволяет легко обнаружить подозрительные ключи и файлы, появившиеся в процессе инсталляции. Забегая вперед, скажу, что такие же «снимки» рекомендуется делать и при прохождении других критических периодов работы программы – при первом запуске, при последнем запуске перед окончанием триального срока, при первом запуске после истечения испытательного срока. Установить факт запуска каких-либо программ во время инсталляции можно при помощи утилит, отслеживающих создание и завершение процессов.
Однако созданием триальных ключей функции программ, запускаемых в процессе инсталляции, не ограничиваются.
Дело в том, что набор функций, поддерживаемых инсталляторами, обычно довольно невелик, и некоторые действия (например, проверку серийного номера с использованием достаточно сложного алгоритма) выполнить средствами инсталляционных скриптов просто невозможно. Один из возможных приемов, применяемых в этом случае – запуск исполняемого файла, который и выполняет все необходимые операции, а затем возвращает управление инсталлятору. В частности, существуют защиты, в которых проверка серийного номера реализована именно так. В некоторых инсталляторах для этих же целей предусмотрен интерфейс, позволяющий использовать плагины (плагин в виде динамически загружаемой библиотеки также упрятываются внутрь инсталлятора, в нужный момент распаковываются во временную директорию и после использования удаляются). Такие исполняемые файлы и плагины, разумеется, невозможно модифицировать напрямую и чаще всего не удается извлечь из инсталлятора для дизассемблирования и изучения, т.к. они хранятся внутри инсталлятора в сжатом виде, а многие коммерческие инсталляторы несовместимы по формату с обычными архиваторами. Если Вы хотите исследовать такой исполняемый файл, Вам почти наверняка потребуется снять с него дамп, чтобы получить материал для загрузки в дизассемблер. Сделать это совсем несложно – запустите инсталлятор под отладчиком и поставьте точки останова на все функции, связанные с загрузкой модулей и библиотек (в случае плагина) или создания процесса (для EXE). В Windows это будут LoadLibrary[Ex], LoadModule[Ex], CreateProcess или устаревший WinExec соответственно. Запомните, откуда инсталлятор пытается загрузить файл, и затем «заморозьте» работу инсталлятора непосредственно перед исполнением этой функции патчем
MySelf: jmp MySelf
либо манипуляциями с атрибутами соответствующего процесса и потока (SuspendThread). Потом скопируйте нужный файл в надежное место - и можете делать с ним все, что захотите.
Одним из важнейших искусств для крэкера, несомненно, является изготовление или добыча серийных номеров.
Как раздобыть серийный номер для программы, которая без этого номера даже не инсталлируется? Варианты «втихаря списать с лицензионного компакта» или «шантажом и пытками вытянуть из зарегистрированного пользователя», мы рассматриваем как не имеющие ничего общего с высоким искусством крэкинга, и потому изучать их не будем. Серийные номера вообще – это очень обширная тема, но в данном разделе я буду рассматривать только те серийные номера, которые «упрятаны» в инсталлятор. Несколькими строками выше я уже говорил о том, как обращаться с проверщиками серийных номеров, запускаемыми в процессе инсталляции. Теперь посмотрим, как можно решить проблему серийного номера, упрятанного в инсталляционный скрипт.
Наиболее удобным для нас был бы вариант с серийным номером, записанным открытым текстом, но так уже практически никто не делает, поскольку «взломать» такую защиту можно при помощи обычного просмотрщика текстов. Так что будем считать, что при проверке правильности используется представление серийного номера как результата некой хэш-функции.
Часто в инсталлятор «зашит» не один серийный номер, а несколько – и это способно существенно облегчить нам задачу. Существует три метода проверки серийного номера:
Различие между п.1 и п.2 не очевидно, но оно есть: именно на втором методе обычно основаны всевозможные «черные списки» серийных номеров. Первый способ проверки в инсталляторах применяется сравнительно редко из-за слабых математических возможностей интерпретаторов инсталляционных скриптов и ориентации на максимальную простоту процесса создания инсталляции (грамотная установка защиты, к нашему счастью, достаточно сложна и при этом все равно не дает гарантированного результата).
Так что в итоге внутри большинства инсталляторов упрятан все тот же список серийных номеров (возможно, из одного элемента). Что интересно, абсолютное большинство инсталляторов никак не упаковывают инсталляционные скрипты (хотя исходный текст скрипта вполне может быть откомпилирован в байт-код), поэтому Вы можете без особых сложностей эти скрипты модифицировать.
Первое, что приходит в голову – найти, где в инсталляторе спрятан этот самый список. Для этого нужно ввести какой-нибудь серийный номер, потом найти код, сравнивающий значение хэш-функции от введенного серийника со списком и вписать свое значение хэш-функции на место оригинального. Что называется, «просто и со вкусом».
Другой способ заключается в том, чтобы идентифицировать программный продукт, при помощи которого сделана инсталляция, затем раздобыть такой же и как следует его проанализировать. Это позволит Вам сравнительно быстро узнать структуру инсталляционного скрипта, технические возможности инсталлятора (и проблемы, которые эти возможности могут Вам создать), способ генерации и формат хранения правильных серийных номеров, коды и функции внутренних команд интерпретатора скриптов. При желании Вы даже сможете написать распаковщик инсталляционных пакетов, декомпилятор инсталляций а то и универсальный(!!!) генератор ключей ко всем программам, инсталляционные пакеты которых сделаны при помощи исследованного Вами программного продукта.
В некоторых случаях наиболее прямым путем к получению полнофункциональной программы из урезанного варианта является именно вскрытие инсталлятора. Поясню эту идею примером. Допустим, что у Вас есть демо-версия какой-либо хорошей программы, но Вам этого мало, и Вы хотите иметь полностью функциональный вариант продукта. При этом у Вас нет никакого желания вручную восстанавливать недостающий код, заботливо выдранный автором. Однако на сайте производителя иногда можно найти обновления для коммерческих версий нужной Вам программы, и эти обновления, как правило, содержат полные и исправленные варианты основных файлов программы.
Что из этого следует? А то, что если неким таинственным образом Вам удастся установить апдейт на демо-версию, есть ненулевая вероятность, что свою демонстрационную версию Вы превратите в программу, по функциональности практически не отличающуюся от полной! Разумеется, программа обновления перед своей установкой выполнит проверку на возможность обновления (в том числе и для того, чтобы пользователь случайно не «обновил» демо-версию) – но ведь Вы решили изучать крэкинг именно для решения проблем именно такого рода. Так что Вам нужно лишь немного «доработать» инсталлятор, ликвидировав в нем проверку на возможность обновления.
По сути, любой инсталлятор представляет собой самораспаковывающийся архив с достаточно сложным SFX-модулем. Более того, некоторые инсталляционные пакеты даже можно открыть обычными архиваторами! Прямым следствием этого является возможность распаковать содержимое инсталляционного пакета (если, конечно, оно не зашифровано). Даже если ни один из стандартных архиваторов «не берет» инсталляционный пакет, распаковать файлы можно вручную. Дело в том, что инсталлятор обязательно содержит внутри себя процедуру распаковки, и эта процедуру можно попытаться проанализировать. Вам понадобится узнать, где находится процедура распаковки, какие параметры она принимает и что эти параметры обозначают (хотя бы приблизительно). Если Вам это удастся, Вы сможете попытаться принудительно вызвать эту процедуру с нужными Вам параметрами, манипулируя кодом программы и состоянием регистров, и извлечь файлы из пакета. Задача поиска этой процедуры облегчается тем, что в инсталляционных скриптах указание на место, куда будут устанавливаться файлы, хранится в виде текста и, поставив точку останова на чтение данных из этих текстовых строк, Вы сможете обнаружить, откуда происходит обращение к этим строкам. Кстати, даже простой анализ текстовых строк, содержащихся в инсталляционном пакете, способен дать множество полезной информации. А теперь представьте себе программу, которая при установке просит некоторое условие, и, если это условие не выполнено, «забывает» установить пару-тройку файлов или устанавливает вместо нормальных версий этих файлов урезанные.
Вот тут-то и нужна возможность заглянуть внутрь архива и извлечь из него нужные файлы.
Глава ???.
Переменные и константы.
Глава ???.
Про функции.
Продемонстрирую эту идею следующим кодом:
if GetSNFromRegistry
then result:=CheckSN
else
begin
result:=not TrialExpired;
if TrialExpired then ShowExpirationMessage
end
Этот код, несмотря на его надуманность, демонстрируeт, что я имею ввиду. Результат, возвращаемый функцией, разрешает программе работать дальше, либо является сигналом к завершению программы. Если в данном примере обезвредить строчку result:=CheckSN путем записи в переменную единицы, после окончания триального срока мы все равно получим ExpirationMessage со всеми вытекающими из этого последствиями. В данном случае было бы более правильно манипулировать с результатом функций GetSNFromRegistry и CheckSN, либо править результат функции TrialExpired.
Глава 6.
Куда попадают данные.
На протяжении уже двух глав я рассказывал о техниках поиска данных всевозможных типов. Подобно муравьям, мы трудились, учась собирать по крупицам полезную информацию, которая могла бы помочь понять структуру и смысл этих данных. И теперь вы знаете, как «просеивать» мегабайты листингов, извлекая из них константы и указатели, как вести поиск переменных в адресном пространстве работающей программы. Все это время мы рассматривали «живые», находящиеся внутри работающей программы данные отдельно от «мертвых», тихо лежащих на жестком диске и никак себя не проявляющих. И вот пришло время осуществить синтез, увидеть диалектическую взаимосвязь между этими двумя формами существования данных, и, главное, понять, какую практическую пользу из этой взаимосвязи можно извлечь.
Нетрудно заметить, что любые данные, хранящиеся на диске, в действительности предназначены для обработки какой-либо программой, а, стало быть, рано или поздно будут загружены в оперативную память (если, конечно, это полезные данные, а не мусор, подлежащий удалению), где над ними будут производиться всяческие действия.
А результаты этих действий так или иначе отобразятся в мире «по нашу сторону экрана» при помощи одного из многочисленных устройств ввода-вывода, чтобы пользователь мог их увидеть, услышать или ощутить каким-либо иным образом. И пока эти данные будут проходить свой непростой путь от загрузки с винчестера до отображения на экране монитора, их можно «выловить» из адресного пространства программы или даже с экрана (то есть, конечно, не совсем с экрана, а из видеопамяти). Более того, информация, которую очень непросто расшифровать, разглядывая файл в шестнадцатиричном редакторе, при загрузке соответствующей программой нередко бывает представлена в памяти в виде структур с весьма незамысловатым внутренним устройством. Да и само наблюдение за процессом загрузки данных может дать множество полезной информации, и в этой главе мы посмотрим, как такую информацию можно извлекать.
Когда-то давным-давно, когда 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… А впрочем, почему только «представьте»? Внедряйте, получайте доступ – и считывайте вожделенную информацию!
И, наконец, не бойтесь пользоваться простейшими методами. Может случиться так, что один из множества инструментов автоматизации, умеющий листать страницы в указанном окне и делать скриншоты, объединенный с программой распознавания текста, поможет Вам получить распечатку защищенного от копирования телефонного справочника быстрее, чем извлечение той же информации из глубин адресного пространства при помощи отладчика. В конце-концов, если информация где-то отображается – значит, ее можно оттуда извлечь и сохранить в желаемом виде – нужно лишь изобрести подходящий метод. Но это уже совсем другая, далекая от крэкинга история.
Глава 2.
Почти начинаем ломать.
Мы откроем сундук, хотя бы пришлось из-за него умереть...
Р.Л. Стивенсон, «Остров сокровищ»
Итак, допустим, что у нас есть программа и в ней содержится вредоносный код (далее - «защита»), который нужно обезвредить. Инсталляционный файл тихо лежит на нашем винчестере, ожидая того знаменательного момента, когда мы его запустим, чтобы извлечь на свет скрытую в его недрах программу. Мне вполне понятно Ваше желание немедленно установить эту программу и вступить в битву со злобным и коварным врагом, но охладите на несколько минут свой пыл, и послушайте мой рассказ о процессе инсталляции программ, и о том, чем этот процесс может кончиться.
Итак, многие программы в настоящее время поставляются в виде инсталляционного пакета. Для установки программы, как правило, требуется либо запустить один из файлов пакета (это, в частности, отлично всем известные Setup.exe), либо открыть при помощи другой заранее установленной программы (к примеру, файлы с расширением MSI, созданные Microsoft’овским инсталлятором или RPM-пакеты в Linux).
В инсталляционных пакетах кроме самих файлов, которые требуется установить на машину пользователя, содержится также описание сценария инсталляции в том или ином виде (назовем это описание сценария для простоты «инсталляционным скриптом», тем более, что чаще всего так оно и есть). Разумеется, инсталлятор может быть и обычной программой, написанной для установки конкретного приложения, но написание собственного инсталлятора – дело достаточно трудоемкое, и поэтому на практике такие инсталляторы встречаются весьма редко.
Инсталляционный скрипт описывает, в каком режиме устанавливается тот или иной файл (добавление, замена, замена с предварительной проверкой версии и т.п.), какие данные необходимо внести в реестр или файлы конфигурации, какие программы запускаются до, в процессе и после инсталляции, а также многое другое. В это «многое другое» могут входить и такие безусловно интересные вещи, как проверка серийных номеров, создание записей в реестре, а также распаковка и запуск программ. О встроенных в инсталляционный скрипт серийных номерах мы поговорим позже. А пока попробуем поразмышлять о том, в чем может заключаться опасность неконтролируемого создания ключей в реестре или запуска каких-либо программ.
Если Вы взламываете какую-либо программу, оснащенную ограничением на время использования или число запусков, один из «корней зла» может гнездиться именно в инсталляционном скрипте. Представьте себе такую ситуацию: программа хранит дату первого запуска и/или какую-либо иную информацию, необходимую для проверки на истечение срока пробного использования, в реестре. Разумеется, информация закодирована, предприняты меры, чтобы защиту не обманывало «подкручивание» системной даты, возможно даже соответствующий ключ реестра хорошо замаскирован (Вам ничего не напоминает мое описание? Да это же ASProtect – ломаный-переломаный, но, как ни странно, все еще популярный). Тем не менее, одна лазейка все-таки осталась – если триальный ключ в реестре отсутствует, защита считает, что раньше программа не запускалась.
Поэтому защиту можно обмануть, просто удалив из реестра лишние ключики. А теперь представьте, что триальный ключ создается в процессе инсталляции, и если он отсутствует, программа просто перестает запускаться. Если Вы ставите целью взлома ликвидацию триальных ограничений, Вам могут потребоваться весьма значительные усилия, чтобы отыскать этот маленький, но зловредный ключик в огромном реестре еще более огромной Windows. Как вам такая перспектива? Если встроенных средств инсталлятора оказывается недостаточно для создания триальной «метки», это можно сделать при помощи небольшого исполняемого файла, который распаковывается в процессе инсталляции, запускается, делает свое черное дело и сразу после этого удаляется. Упрощенный вариант этого приема выглядит как автоматический запуск приложения после инсталляции, чтобы защита смогла создать триальные «метки» на компьютере пользователя.
В качестве метки, на основе которой программа будет проверять ограничения по времени или числу запусков, также может использоваться какой-либо файл, создаваемый в процессе инсталляции, особенно если этот файл запрятан в глубинах одной из системных директорий (да-да, я знаю, что мусорить в системных директориях нехорошо; осталось лишь объяснить это некоторым авторам защит) и при этом еще активно используется программой. Я встречал защиту, основанную на подобном принципе: дата создания одной из DLL использовалась для отсчета времени с момента установки, а сама DLL лежала в системной директории Windows среди сотен себе подобных и динамически подгружалась основной программой.
Какие средства мы можем применить, чтобы обнаружить и обезвредить эти и другие подобные приемы? Наиболее радикальным средством, разумеется, является декомпиляция инсталляционного скрипта – в этом случае мы получаем практически полную информацию о том, что происходит в процессе установки программы, а в некоторых случаях даже можем повлиять на этот процесс, внеся исправления в инсталлятор. Разобрать инсталляционный скрипт «по косточкам» - задача не самая простая, да и не всегда это необходимо.
Поэтому на практике чаще пользуются другим типовым приемом, позволяющим обнаружить произошедшие изменения. Этот прием заключается в использовании утилит мониторинга, делающих «снимки» системы (реестра, размеров и дат создания/модификации файлов) до и после установки и затем анализирующих различия между снимками. Подробный журнал изменений, выдаваемый такими программами, позволяет легко обнаружить подозрительные ключи и файлы, появившиеся в процессе инсталляции. Забегая вперед, скажу, что такие же «снимки» рекомендуется делать и при прохождении других критических периодов работы программы о которых мы поговорим в следующей главе. Установить факт запуска каких-либо программ во время инсталляции можно при помощи утилит, отслеживающих создание и завершение процессов.
Однако деятельность программ, запускаемых в процессе инсталляции, не ограничивается одним лишь созданием триальных ключей. Дело в том, что набор функций, поддерживаемых инсталляторами, ограничен и некоторые действия (например, проверку серийного номера с использованием достаточно сложного алгоритма) выполнить средствами инсталляционных скриптов не всегда возможно. Один из приемов, применяемых в этом случае – запуск исполняемого файла, который и выполняет все необходимые операции, а потом тем или иным образом возвращает результат проверки в инсталлятор - существуют защиты, в которых проверка серийного номера реализована именно так. В некоторых инсталляторах для этих же целей предусмотрен интерфейс, позволяющий использовать плагины (плагин в виде динамически загружаемой библиотеки также упрятываются внутрь инсталлятора, в нужный момент распаковываются во временную директорию и после использования удаляются). Такие исполняемые файлы и плагины, разумеется, невозможно модифицировать напрямую и чаще всего не удается извлечь из инсталлятора для дизассемблирования и изучения, т.к. они хранятся внутри инсталлятора в сжатом виде, а большинство коммерческих инсталляторов несовместимы по формату с обычными архиваторами.
Если Вы хотите исследовать такой исполняемый файл, Вам почти наверняка потребуется снять с него дамп, чтобы получить материал для загрузки в дизассемблер. Сделать это совсем несложно – запустите инсталлятор под отладчиком и поставьте точки останова на все функции, связанные с загрузкой модулей и библиотек (для плагина) или создания процесса (для EXE). В Windows это будут LoadLibrary[Ex], LoadModule[Ex], CreateProcess или устаревший WinExec соответственно. Запоминаем, откуда инсталлятор пытается загрузить файл, и затем «замораживаем» работу инсталлятора непосредственно перед исполнением этой функции патчем
MySelf: jmp MySelf
либо манипуляциями с атрибутами соответствующего процесса и потока (например, применив к нему функцию SuspendThread). Затем копируем нужный нам файл в надежное место и делаем с ним все, что только придет нам в голову.
Если такой файл, запускаемый во время инсталляции, работает сколь-нибудь длительное время (достаточное, чтобы обнаружить факт появления нового процесса и записать данные в его адресное пространство), Вы даже можете создать memory patch, позволяющий модифицировать код этого файла в памяти. Приведу практический пример: одна из программ в процессе установки запрашивала пароль, который было необходимо ввести, чтобы продолжить инсталляцию. Путем экспериментов удалось установить, что инсталлятор создавал в директории для временных файлов исполняемый файл со случайным именем, запускал его и затем блокировал доступ к файлу даже на чтение, после чего собственно ввод пароля и его проверка осуществлялась уже средствами этой запущенной программы. Заставить программу «признать» любой пароль при помощи правки кода программы в SoftIce не составляло особой сложности, но вот изготовить «классический» патч, который позволил бы устанавливать эту программу, не прибегая к помощи отладчика, оказалось крайне затруднительно. Но, поскольку ввод пароля осуществлялся в стандартном окне Windows, это дало возможность подойти к проблеме с другой стороны.
После недолгих размышлений выяснилось, что можно создать программу-launcher, выполняющую следующие действия:
Одним из важнейших искусств для крэкера, несомненно, является изготовление или добыча серийных номеров. Как заполучить серийный номер для программы, которая без этого номера даже не инсталлируется? Варианты «втихаря списать с лицензионного компакта» или «шантажом и пытками вытянуть из зарегистрированного пользователя», мы рассматриваем как не имеющие ничего общего с высоким искусством крэкинга, и потому изучать их не будем. Серийные номера вообще – это очень обширная тема, и в данном разделе я буду рассматривать только те серийные номера, которые «упрятаны» в инсталлятор. Несколькими строками выше я уже говорил о том, как обращаться с проверщиками серийных номеров, запускаемыми в процессе инсталляции. Теперь посмотрим, как можно решить проблему серийного номера, упрятанного в инсталляционный скрипт.
Наиболее удобным для нас был бы вариант серийного номера, записанного открытым текстом, но так уже практически никто не делает, поскольку «взломать» такую защиту можно при помощи обычного просмотрщика текстов. Так что будем считать, что при проверке правильности используется представление серийного номера как результата некой хэш-функции.
Часто в инсталлятор «зашит» не один серийный номер, а несколько – и это способно существенно облегчить нам задачу. Существует три метода проверки серийного номера:
Очевидно, что это наиболее простой случай: требуется только обнаружить точку, в которой происходит сравнение введенного серийного номера с правильным и прочитать из памяти действительный серийный номер.
Различие между п.1 и п.2 не очевидно, но оно есть: именно на втором методе обычно основаны всевозможные «черные списки» серийных номеров. Первый способ проверки в инсталляторах применяется сравнительно редко из-за слабых математических возможностей интерпретаторов инсталляционных скриптов и ориентации на максимальную простоту процесса создания инсталляции (грамотная установка защиты, к нашему счастью, достаточно сложна и при этом все равно не дает гарантированного результата). Так что в итоге внутри большинства инсталляторов упрятан все тот же список серийных номеров (возможно, лишь из одного элемента). Еще интересно отметить, что абсолютное большинство инсталляторов никак не упаковывают инсталляционные скрипты (хотя исходный текст скрипта вполне может быть откомпилирован в байт-код), поэтому Вы можете сравнительно легко модифицировать эти скрипты при помощи шестнадцатиричного редактора.
Первое, что приходит в голову – найти, где в инсталляторе спрятан этот самый список и добавить туда свой серийник. Для этого нужно ввести какой-нибудь серийный номер, потом найти код, сравнивающий значение хэш-функции от введенного серийника со списком и вписать свое значение хэш-функции в файл инсталляции на место оригинального. Что называется, просто и со вкусом.
Другой способ заключается в том, чтобы идентифицировать программный продукт, при помощи которого сделана инсталляция, затем раздобыть этот продукт и как следует его проанализировать. Это позволит Вам сравнительно быстро узнать технические возможности инсталлятора (и проблемы, которые эти возможности могут Вам создать), структуру инсталляционного скрипта, способ генерации и формат хранения правильных серийных номеров, коды и функции внутренних команд интерпретатора скриптов. При желании Вы даже сможете написать распаковщик инсталляционных пакетов, декомпилятор инсталляций а то и универсальный(!!!) генератор ключей ко всем программам, инсталляционные пакеты которых сделаны при помощи исследованного Вами программного продукта.
В некоторых случаях наиболее прямым путем к получению полнофункциональной программы из урезанного варианта является именно вскрытие инсталлятора. Поясню эту идею примером. Допустим, что у Вас есть демо-версия какой-либо хорошей программы, но Вам этого мало, и Вы хотите иметь полностью функциональный вариант этого продукта. При этом у Вас нет никакого желания вручную восстанавливать недостающий код, заботливо выдранный автором. Однако на сайте производителя иногда можно найти обновления для коммерческих версий нужного Вам софта, и эти обновления, как правило, содержат исправленные варианты основных файлов программы. Что из этого следует? А то, что если неким таинственным образом Вам удастся установить апдейт на демо-версию, есть ненулевая вероятность, что свою демонстрационную версию Вы превратите в программу, по функциональности практически не отличающуюся от полной! Разумеется, программа обновления перед своей установкой выполнит проверку на возможность обновления (в том числе и для того, чтобы пользователь случайно не «обновил» демо-версию) – но ведь Вы решили изучать крэкинг именно для решения проблем такого рода. Так что Вам потребуется лишь немного «доработать» инсталлятор, ликвидировав в нем проверку на возможность обновления.
По сути, любой инсталлятор представляет собой самораспаковывающийся архив с достаточно сложным SFX-модулем. Более того, некоторые инсталляционные пакеты даже можно открыть обычными архиваторами! Прямым следствием этого является возможность распаковать содержимое инсталляционного пакета (если, конечно, оно не зашифровано). Даже если ни один из стандартных архиваторов «не берет» инсталляционный пакет, распаковать файлы можно вручную. Дело в том, что инсталлятор обязательно содержит внутри себя процедуру распаковки, и эта процедуру можно обнаружить и проанализировать. Вам понадобится узнать, где находится процедура распаковки, какие параметры принимает, и что эти параметры обозначают (хотя бы приблизительно). Если Вам это удастся, Вы сможете попытаться принудительно вызвать эту процедуру с нужными Вам параметрами, манипулируя кодом программы и состоянием регистров, и извлечь файлы из пакета.
Задача поиска этой процедуры облегчается тем, что в инсталляционных скриптах указание на место, куда будут устанавливаться файлы, хранится в виде текста и, поставив точку останова на начала этих текстовых строк, Вы сможете обнаружить, откуда происходит обращение к этим строкам. Вообще, даже простой анализ текстовых строк, содержащихся в инсталляционном пакете, способен дать множество полезной информации. А теперь представьте себе программу, которая при установке просит некоторое условие, и, если это условие не выполнено, «забывает» установить пару-тройку файлов или устанавливает вместо нормальных версий этих файлов урезанные. Вот тут-то и Вам и пригодится возможность заглянуть внутрь архива и извлечь из него нужные файлы.
Глава 3.
«Критические дни» программ.
Все боится времени, но само время боится Пирамид
Египетская пословица
После того, как мы успешно установили программу, неизменно наступает торжественный момент первого запуска программы (конечно, если инсталлятор не испортил нам удовольствие, запустив программу автоматически). Немало программ содержат ограничения на время пробного использования, и потому необходимо со всей ответственностью отнестись к первому (да и не только к первому, как мы увидим в дальнейшем) запуску программы. Дело в том, что процесс исследования защиты может затянуться и Вам элементарно не хватит тех дней, которые отведены под пробное использование программы. Вы, разумеется, захотите продлить время пользования программой – и вот тут Вас может ожидать весьма неприятный сюрприз.
В общем случае задачи по неограниченному продлению триального времени программы обычно не представляют особой сложности, но в качестве дополнительных уровней защиты часто присутствуют всевозможные навесные защиты, ликвидация которых может потребовать гораздо больших трудозатрат. Поэтому надо, по возможности, предупреждать подобные ситуации, а о том, каким образом эти ситуации могут возникать, я и расскажу в этом разделе.
Возможны и другие причины, по которым Вам может потребоваться снять ограничение на время использования программы. Для некоторых программ процедура регистрации не предусмотрена в принципе, но при этом в течение испытательного срока они выполняют свои функции в полном объеме (часто такие программы помечены как Demo- и Evaluation-версии), и вполне приемлемым решением было бы неограниченное продление триального срока. Случается также, что необходимо установить бета-версию программы, у которой истек «срок годности», но более новой беты нет, а посмотреть на программу очень хочется. Так или иначе, снятие ограничений на время использования или число запусков программ – одна из актуальных задач современного крэкинга.
Представьте себе, что Вы – автор программы, и Ваша задача – ограничить «испытательный срок», в течение которого пользователь может работать с программой. Как такое можно реализовать? Выбор мест хранения информации об испытательном сроке в современных ОС довольно небогатый – содержимое некоего файла (который можно попытаться спрятать), атрибуты файлов, либо системный реестр (это актуально только для ОС Windows). Такие изменения могут быть обнаружены при помощи утилит, умеющих создавать и сравнивать снимки состояния системы. Изредка встречаются нетрадиционные решения вроде манипуляций на уровне файловой системы или использования слэков. Использование слэков для защиты основано на том, что последний кластер, занятый файлом, обычно заполнен не целиком, и потому в незаполненной (и невидимой для большинства программ, оперирующих с файлами) можно хранить некоторый объем информации.
В принципе, идеологических различий между реестром Windows, файлами и атрибутами отдельных файлов нет, все эти объекты могут использоваться для хранения используемых защитой данных. Действительно, аналогии в устройстве реестра и дисковой подсистемы очевидны: «ветви» реестра играют роль логических дисков, разделы – практически полные аналоги папок (убедиться на наглядном примере можно, взглянув на иконки разделов в «Редакторе реестра»), имена ключей – это имена файлов, а значения ключей – содержимое этих файлов.
Аналогии можно продолжить, но для нас важно другое: поскольку файловая система подобна реестру, принципы поиска и ликвидации защитных механизмов, основанных на сокрытии информации на дисках или в реестре, во многом будут сходны. Поэтому далее я буду говорить в основном о реестре, оставляя читателю самому разобраться в том, как аналогичные механизмы могут быть реализованы на основе файловой системы (или почему они не могут быть реализованы).
Конечно, все разнообразие механизмов ограничения времени использования программы или количества запусков охватить нереально, но в этом и нет необходимости – воспользовавшись своим воображением, Вы легко продолжите список. Я лишь перечислю основные события в «жизни» программы, и их возможную связь с реализацией триальных механизмов в программах.
Первое, что приходит в голову – во время первого запуска сохранить в файле или в реестре дату (или вычислить дату, после которой программа не должна работать) и при каждом запуске сравнивать текущую дату с сохраненной, проверяя, не истек ли «срок годности» программы. Такие программы, не обнаружив в реестре пометки о дате первого запуска, как правило, считают, что текущий запуск – первый. Такое простейшее решение, как правило, и обходится простейшими средствами – достаточно обнаружить и удалить соответствующий файл или ключ реестра. Одна из модификаций этого метода – определение даты первого запуска через чтение атрибутов файлов: при создании любого файла атрибут CreationTime (дата создания файла) устанавливается автоматически, что позволяет непосредственно в процессе инсталляции «промаркировать» все устанавливаемые файлы датой установки программы. Затем программа просто проверяет при каждом запуске дату создания какого-либо файла или папки (или вообще всех файлов, созданных в процессе инсталляции) и на основе этой информации вычисляет количество дней до истечения испытательного периода. Что интересно, свойства файлов могут использоваться не только для определения даты установки программы, но и для определения текущей даты: в процессе своей работы отдельные компоненты ОС нередко ведут всевозможные «журналы» (логи) в файлах с заведомо известными именами.
Проверив дату последней модификации такого файла или его содержимое, можно с некоторой погрешностью узнать текущую дату.
Как было показано выше, надежность такого метода довольно низкая и защита ориентирована в основном на психологический эффект. Более устойчивые варианты этой защиты основаны на использовании «меток», оставляемых программой в реестре в некоторые критические моменты.
Метки, создаваемые при инсталляции, обычно предназначены для предотвращения повторной установки программы (и получения дополнительного триального срока) и для противодействия попыткам «сбросить» счетчик времени удалением ключа реестра. Если инсталлятор не позволяет выполнять сложные вычисления внутри инсталляционного скрипта, метка может выглядеть как запись в реестре с фиксированным значением, наличие которого будет проверяться при первом запуске. Если для создания инсталляционного пакета использовался достаточно мощный продукт, дата инсталляции или максимальное число запусков программы может быть прописано в реестре уже в процессе установки (возможно, в зашифрованном виде). Кроме того, инсталлятор может проверить наличие сделанных ранее «меток» в реестре для предотвращения повторной инсталляции. Таким образом, гарантированно вернуть программу в рабочее состояние после окончания триального срока можно только удалением всех «меток», оставленных программой, с последующей переустановкой.
Многие из ныне существующих защит создают «метки» в момент первого запуска программы. Факт первого запуска обычно определяется простейшим способом: если программа при запуске не обнаруживает «метку», она считает, что запущена впервые, и создает «метки». Обычно создание «меток» выполняется сразу после начала исполнения программы, но изредка встречаются программы, которые выполняют эти действия при завершении программы (возможно, что существуют защиты, которые делают это в случайный момент времени). Скорее всего, таким образом разработчики защит пытались затруднить выявление защитного кода, но на деле они добились прямо противоположного эффекта: большинство операций по загрузке данных из реестра выполняется именно при запуске или изменении настроек программы, а вот запись в реестр в конце сеанса работы – операция менее распространенная.
При завершении сеанса обычно сохраняется только информация о положении окон, настройках интерфейса программы и прочие подобные данные, которые, как правило, несложно отличить от защитных механизмов программы. Очевидно, что если защита использует исключительно «метки», создаваемые при первом запуске, после удаления этих «меток» программа считает, что запущена впервые, и начинает отсчет триального срока заново. Для того, чтобы пользователи не обходили эту защиту совсем уж элементарными средствами вроде удаления соответствующего ключа реестра, ключ, содержащий в себе защитную информацию, может быть «плавающим», то есть имя ключа может генерироваться случайным образом в зависимости от аппаратной конфигурации компьютера или каких-либо иных параметров. Найти вручную такой ключ среди десятков подобных практически нереально. В частности, именно такой механизм использует ASProtect. Поскольку «плавающий» ключ, сколь успешно бы он не был замаскирован, все-таки отличается от окружающих его ключей, определив признаки, которые отличают «плавающий» счетчик от обычных ключей реестра, возможно создать программу, которая бы автоматически выявляла подозрительные ключи, что подтверждается наличием как минимум трех независимо разработанных утилит, способных выявлять и удалять ключи, созданные ASprotect’ом. В любом случае, изменения в реестре, возникшие после первого запуска программы, нетрудно обнаружить при помощи программ мониторинга реестра.
Важно отметить, что программы, в которых установлено ограничение на число пробных запусков, при первом запуске всегда создают либо модифицируют один из ключей реестра. Это напрямую следует из логики их работы: для работы такой защиты необходим счетчик запусков, а создать такой счетчик можно только во время инсталляции либо при первом запуске.
Разумеется, программа, содержащая ограничение на число запусков, должна увеличивать либо уменьшать значение этого счетчика после каждого запуска. Кроме того, запись в реестр при каждом запуске возможна также и при ограничении по времени использования.
Такая запись играет роль дополнительной защиты от изменения системного времени: если дата текущего запуска меньше даты предыдущего или отличается от него на считанные секунды, программа может предположить, что пользователь пытается использовать программу сверх установленного срока. Обнаружить такую защиту сравнительно легко – многократную модификацию одного и того же ключа скрыть практически невозможно, не помогают даже такие приемы, как дублирование счетчика и «плавающий» счетчик. Сбрасывается такой счетчик тоже без особых сложностей – достаточно один раз запомнить состояние соответствующих ключей реестра и затем восстанавливать его перед каждым запуском.
Интересно отметить, что даже столь простую идею, как отслеживание времени, некоторые разработчики ухитряются реализовать некорректно (по крайней мере, под ОС Windows такое встречается не так уж редко). Windows позволяет оперировать двумя типами времени: системным (оно же «всемирное», UTC) и местным (Local). Причем во многих странах местное время может быть зимним и летним. И если защита ориентируется на местное время, в день перевода часов пользователя может ожидать сюрприз: после автоматического перевода часов, выполняемого ОС, программа может просто перестать работать. Для этого нужно лишь, чтобы пользователь запустил программу непосредственно перед переходом с летнего времени на зимнее, и потом – еще один раз в течение ближайшего часа. По этому нехитрому признаку можно в известной степени судить о квалификации разработчика защиты.
Наконец, последним важным моментом в жизни программы является ее первый запуск после истечения срока триального использования. Поскольку большинство лицензионных соглашений запрещают пробное использование программы сверх установленного «испытательного срока», программа может «помочь» пользователю соблюсти условия лицензионного соглашения, сделав в реестре метку, запрещающую дальнейшую работу программы. Эта метка может быть как новым ключом в глубинах реестра (или файлом в какой-нибудь системной директории), так и особым значением уже существующего ключа.
Проверка значения такого ключа также может быть встроена в инсталлятор программы, чтобы пользователь не мог просто деинсталлировать и переустановить программу.
Какие выводы следуют из всего, что было сказано выше? Во-первых, то, что триальные механизмы в программах могут быть довольно разнообразны по исполнению (и, кроме того, иногда дублируются разработчиком для повышения надежности), а их поиск и удаление требует аккуратности и терпения. Несмотря на то, что создание снимков системы требует некоторого времени, пренебрежительное отношение к этим действиям может создать Вам значительные трудности в будущем. Во-вторых, существуют критические точки, при прохождении которых рекомендуется отслеживать состояние системы. В-третьих, можно составить универсальный план действий, который с очень высокой вероятностью позволил бы выявить изменения в системе, вносимые триальными механизмами, проанализировать их и выработать способы противодействия защите. Лично я чаще всего использую следующую последовательность действий, придерживаться которой рекомендую и Вам:
Если программа содержит ограничение по времени использования, то мы можем установить, использует ли программа какие-либо дополнительные механизмы отслеживания времени. О наличии таких механизмов может свидетельствовать появление в реестре новых ключей или изменение уже существующих, появившихся в ходе инсталляции или первого запуска. Если такие ключи обнаружатся, есть вероятность, что программа кроме даты первого запуска отслеживает еще и дату предыдущего запуска, число дней, в течение которых использовалась программа или другую подобную информацию. Для максимальной надежности и достоверности результатов эту операцию можно повторить несколько раз.
Вышеперечисленные приемы еще недавно считались «некрасивыми», и рассматривались как побочный результат исследования программы. Однако в последние годы ситуация начала меняться: лавинообразный рост количества защищенных программ привел к тому, что даже довольно известные группы обратили внимание на технологии продления сроков пробного пользования и выпускают launcher’ы, продлевающие триальные сроки и сбрасывающие счетчики числа запусков. Что ж, изменившиеся условия требуют новых решений, и если эти решения эффективны, нет никаких причин от них отказываться.
Кроме того, серьезное исследование какой-либо защиты может потребовать неоднократного запуска приложения, и отведенное число запусков может закончиться раньше, чем удастся разобраться в программе. Кроме того, выявление счетчиков и «меток» - это один из путей поиска реализации триальных механизмов непосредственно в коде программы. К примеру, если Вы установили, что имя ключа, отвечающего за триальные ограничения, фиксировано, Вы можете попытаться найти имя этого ключа в тексте программы, затем выявить все ссылки на эту строку в программе и, в итоге, добраться до кода, реализующего защитные механизмы. Также при помощи API-шпиона можно проследить за вызовами функций обращения к реестру, проанализировать параметры, передаваемые этим функциям и таким образом попытаться выяснить, какой из этих вызовов непосредственно связан с реализацией ограничений в программе. А затем, зная, каким образом можно сбросить счетчик, ограничивающий возможность работы с программой, останется лишь воспроизвести этот эффект путем модификации кода программы.
Глава 4.
Переменные и константы.
Одна из первых задач, с которыми сталкивается крэкер - поиск в тексте программы констант или обращений к переменным, отвечающих за реализацию ограничений в незарегистрированных и демонстрационных программах. Нередко также возникает необходимость извлечь из программы какие-либо данные, и, возможно, заменить их своими собственными. Поскольку в данной главе пойдет речь не только об изучении, но и об изменении программ, сразу же ознакомлю Вас с одним из важнейших, с моей точки зрения, принципов, которым нужно руководствоваться, если приходится прибегать к модификации данных или исполняемого кода. Это принцип минимального вмешательства, который можно сформулировать так:
Чем меньше изменений внесено в логику программы, тем менее вероятно, что эти изменения приведут к некорректной работе программы.
Приблизительный рейтинг изменений по степени их влияния на логику программы (от минимального к максимальному) выглядит следующим образом:
- Модификация константы
- Изменение значения предварительно инициализированной переменной
- Изменение условия перехода на противоположное
- Удаление линейной, то есть не содержащей ветвлений и вызовов нетривиальных подпрограмм, последовательности команд
- Дописывание в программу собственного кода; изменение значения, возвращаемого функцией
- Модификация внедренного в программу ресурса, важного для логики программы (т.е. такого, изменение которого меняет поведение программы)
- Удаление из программы логического блока (например, вызова нетривиальной функции); удаление внедренного в программу ресурса
Этот принцип, как любое другое широкое обобщение, требует достаточно осторожного отношения и в некоторых случаях может повести Вас по неоптимальному пути. Но на практике принцип минимального вмешательства обычно работает вполне успешно. Да и с точки зрения простого здравого смысла очевидно, что найти и исправить условие, которое проверяет зарегистрированность программы – прием куда более безобидный и надежный, чем вырывание с корнем 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 и получил листинг текст длиной более 180 000 строк. Казалось бы, обнаружить область, где происходит сравнение с числом 50 в этом листинге ничуть не проще, чем найти иголку в стоге сена. Но я воспользовался функцией поиска в тексте строки, в качестве параметра поиска указав 00000032 (так в W32Dasm отображается число 50; заодно это позволило отсеять команды вроде mov eax,[ebx+32h], обычно использующиеся для доступа к элементам массивов и полям структур). Реальность превзошла самые смелые ожидания: четырехбайтное число 32h встретилось в листинге всего два (!!!) раза:
:004478C6 6A32 push 00000032
и
:004505BB 83F832 cmp eax, 00000032
Чтобы догадаться, что нужное нам сравнение выполняется во второй строке, достаточно самых минимальных познаний в ассемблере. Из этого следует вывод: поиск нужной константы в дизассемблированной программе вполне реален, даже несмотря на огромный объем листинга.
Далее: Вам наверняка интересен не сам факт наличия константы где-то в недрах кода, а то, в каком контексте эта постоянная используется. Иными словами, если Вы знаете, что программа сравнивает число записей в базе данных с некоторым значением (в нашем случае - 32h), то среди всех строк, в которых присутствует эта константа, в первую очередь следует рассматривать команды сравнения (cmp) и вычисления разности (sub и sbc). Хотя нельзя забывать о существовании менее очевидных способов сравнения, например, таких:
mov ebx,50
cmp eax, ebx
или
push 50
push eax
call Compare2dwords
Ну и, раз уж речь зашла о сравнениях, нельзя не упомянуть об альтернативных вариантах реализации этой, казалось бы, нехитрой операции.
Поразмыслим над приведенным выше примером сравнения содержимого регистра 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_40D5A7cmp dword_40D5A7, 136hjbe 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/С++, в заголовке окна которой написано что-то вроде «Cool Program - Unregistered», и Вы не хотите видеть напоминание о том, что она «Unregistered», просто замените в файле программы пробел после слова Program на символ с кодом 0. После этого слово «Unregistered» Вы почти наверняка больше не увидите. Этим же способом ненужную строку можно вообще превратить в пустую, просто поставив в ее начало завершающий символ!
Описанная техника укорачивания и «обнуления» строк пригодна не только для того, чтобы убирать неэстетичные надписи в заголовках программ, в действительности ее возможности гораздо шире. Приведу пару примеров из собственной практики. Один раз мне в руки попалась некая программа, которая очень любила при печати документов в заголовок вставлять надпись «This report created by demo version …» шрифтом аршинного размера. Разумеется, мне это не понравилось и при помощи нехитрых манипуляций в шестнадцатиричном редакторе я «обнулил» строку с надписью, оскорблявшей мои эстетические чувства. В другом случае я подобным же образом расправился с одной программой-генератором справок, которая считала, что незарегистрированность – это повод вставлять рекламный текст в каждую статью справочной системы. Небольшой memory patch, исправлявший в программе «на лету» несколько байт, смог убедить капризную программу в ее принципиальной неправоте.
Более эффективными по сравнению с ASCIIZ-строками являются строки с указанием длины. Такие строки позволяют использовать в тексте все 256 ASCII-символов, хранить не только текстовые, но и любые другие двоичные данные, а также применять по отношению к этим данным строковые функции. Кроме того, вычисление длины строки требует лишь одной операции чтения данных по ссылке, в отличие от ASCIIZ-строк, где для определения длины необходимо последовательно сканировать все символы строки до тех пор, пока не встретится завершающий символ. Как такового, стандарта на строки с указанием длины не существует – можно лишь говорить о конкретных реализациях таких строк в различных компиляторах и библиотеках. В частности, в коде программ на Delphi 7 строковые константы хранятся следующим образом:
Зная все это, нетрудно разработать способ укорачивания Delphiйских строк: для этого требуется изменить длину строки в первых четырех байтах и поставить еще один завершающий символ в нужную позицию. Надо отметить, что тексты-свойства компонентов в ресурсах программ на Delphi хранятся в несколько ином формате, поэтому прежде чем пытаться вмешиваться в код программы, стоит побольше узнать об особенностях реализации встроенных типов в компиляторе, при помощи которого создана исследуемая программа.
Поиск текстовых констант в программе – совсем не такое простое дело, как это могло бы показаться с первого взгляда. Прежде всего, не следует полагаться на «интеллектуальность» дизассемблера: поиск текстовых строк при дизассемблировании обычно основан на анализе ссылок, встречающихся в коде программы.
Поэтому, если в коде программы нет прямой ссылки на строку, дизассемблер эту строку может просто «не увидеть». Чтобы убедиться в этом, рассмотрим несложный пример:
.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]
ret
GetMsgAddr endp
…
Этот код представляет собой максимально упрощенную реализацию списка сообщений и функции, получающей адрес текстовой строки по номеру сообщения. Откомпилировав этот пример, загрузим его в W32Dasm и посмотрим, что получится. Получилось следующее: дизассемблер успешно распознал строку «Line 1», но строки «Line 2» и «Line 3» не обнаружил. А вот IDA успешно распознал все три строки, и создал для них именованные метки. Впрочем, и IDA при большом желании можно обмануть: достаточно лишь вписать перед текстом самой строки ее длину в байтах (именно так хранит строки Delphi). После этого IDA хотя и обнаруживает сам факт наличия текстовых строк в программе (в окне Strings эти строки видны), но в дизассемблированном тексте программы эти строки выглядят как последовательность db… , которые нужно приводить в желаемый вид вручную. Кстати, W32Dasm после этой модификации не увидит вообще ни одной строки. Если же Вам и этого мало, вместо «Line 1» напишите «Строка 1» - все тексты на русском языке знаменитый дизассемблер гордо проигнорирует. И это только начало. А ведь текстовые строки могут находиться не только в сегменте кода/инициализированных данных, но и в секции ресурсов программы…
Здесь могут помочь специализированные программы, сканирующие указанный файл и вычленяющие из него все текстовые строки (или то, что похоже на текстовые строки). Кроме того Вам потребуются смещения этих строк от начала файла, поэтому Ваш инструмент должен предоставлять и такой сервис. Однако использование таких программ (и самостоятельное их написание) осложняется двумя факторами: разнообразием существующих кодировок текста и существованием национальных символов в некоторых языках (классический strings.exe и многие другие подобные программы «не понимают» русскую секцию UNICODE).
Те же проблемы с UNICODE и национальными кодировками характерны и для программного обеспечения, осуществляющего поиск в текстовых файлах. К тому же русские тексты в UNICODE совершенно нечитабельны в шестнадцатиричных редакторах и просмотрщиках. Все это необходимо учитывать при выборе инструментов поиска текстовых строк, а выбранный инструмент перед использованием желательно проверить на подходящем «пробном камне».
Напоследок расскажу про весьма простой, но весьма эффективный в некоторых случаях способ поиска численных переменных в работающей программе. Этот способ основан на многократном сканировании адресного пространства программы, отслеживании и анализе всех изменений в этом пространстве. Лучше всего этот прием работает на программах, в которых установлено ограничение на количество тех или иных действий, вроде ограничения на число записей, добавляемых в документ. И используется для этого совсем не крэкерский инструментарий. Вы, наверное, знакомы с программами типа Game Wizard или ArtMoney, которые позволяют искать в работающей компьютерной игре количество денег или оставшихся жизней. Для тех, кто не сталкивался с такими программами, вкратце опишу алгоритм их работы:
1. Пользователь выбирает из списка работающих в данный момент программ подопытную игру.
2. Пользователь вводит в программу поиска начальное количество денег (хитов и т.п.), которое в данный момент существует в игре.
3. Программа сканирует адресное пространство и строит список всех значений (точнее, адресов, по которым расположены эти значения), совпадающих с введенными пользователем.
4. Пользователь выполняет в игре какое-либо действие, в результате которого количество денег изменяется.
5. В программу поиска вводится новое количество денег.
6. Программа проверяет все значения из построенного списка и исключает из него те значения, которые не соответствуют введенному пользователем.
7. Пункты 4-6 повторяются до тех пор, пока список адресов не укоротится настолько, чтобы можно было проверить назначение каждого элемента списка вручную.
8. Пользователь проверяет каждый элемент списка, записывая по найденным адресам новые значения и наблюдая, как это повлияет на количество денег в игре.
Проницательный читатель наверняка уже догадался, что защищенная программа ничем в принципе не отличается от компьютерной игры, а число добавленных в документ записей – это то же самое, что количество виртуальных «золотых монет». И потому, воспользовавшись соответствующей программой (например, все той же ArtMoney), можно определить адреса всех переменных, которые могут хранить счетчик добавленных в документ записей. Дальнейшие действия зависят исключительно от Вашего желания – можно поставить аппаратную точку останова на чтение из этой переменной и попытаться добраться до команды сравнения существующего числа записей с максимальным. Можно погрузиться в изучение дизассемблерного листинга в поисках команды увеличения переменной и сделать так, чтобы значение счетчика не увеличивалось. Можно даже попробовать модифицировать счетчик из ArtMoney, посмотреть, что из этого получится, и если из этого получится что-то хорошее – написать memory patch, каждые 100 миллисекунд обнуляющий счетчик.
На этом мой рассказ о простых типах данных в основном закончен, а информацию о методах хранения составных типов, таких как структуры и массивы, о том, где обычно в программах лежат константы, а также об особой роли указателей Вы найдете в следующей главе.
Глава 5.
Структуры, массивы, ссылки.
Читая предыдущую главу, Вы, наверное, были неприятно удивлены тем, с какими сложностями можно столкнуться, решая такую нехитрую задачу, как поиск в программе нужной константы. И, возможно, подумали, что если простые числовые и символьные константы способны создать столько проблем, то каково же будет разбираться в составных типах данных! Поспешу Вас успокоить – разобраться в структурированных данных обычно не слишком сложно именно в силу их регулярной структуры. К тому же, в этой главе мы не будем рассматривать такие достаточно сложные вещи, как тонкости представления текста в различных кодировках, и размышлять над глобальными проблемами машинного округления – это уже пройденный этап, и, я думаю, Вы сможете эффективно применить эти знания без дополнительных подсказок.
Впрочем, простота теоретической части обсуждаемой нами темы компенсируется сложностями при практическом применении предлагаемых Вам знаний.
Сразу отмечу, что эта глава посвящена не столько непосредственному взлому программ, сколько техникам «вытаскивания» структурированной информации, упрятанной в недрах исполняемых файлов. Впрочем, техники, описанные в этой главе, с некоторыми модификациями могут быть применены и к другим типам файлов, хранящих структурированные данные. Это может быть полезно, к примеру, для тех, кому потребуется извлечь из программы какие-либо данные или «расколоть» базу данных заранее неизвестного формата.
Рассказывая о нецелочисленных константах, я упоминал о том, что обращения к таким константам производятся по указателю, то есть в команде, загружающей действительное число в регистр сопроцессора, в явном виде присутствует адрес загружаемого числа. Однако доступ к данным по указателю на них характерен не только чисел с плавающей точкой (на некоторых платформах числа с плавающей точкой отлично умещаются в регистрах общего назначения), но и для строк, структур и массивов. Теоретически, небольшую структуру или короткий массив еще можно было бы попытаться разместить в регистрах общего назначения (собственно, при предельной оптимизации кода иногда так и поступают), но большинство реальных данных требуют для своего хранения гораздо больше места, чем могут предоставить регистры процессора. Наиболее простым и быстрым способом работы с такими объемными данными является хранение этих данных в оперативной памяти и обращение к ним через посредство указателей.
Теперь перейдем к практическим аспектам использования указателей в программах. Здесь работают три очень простых правила:
Это, конечно, очень широкое обобщение (и не всегда верное – достаточно вспомнить пример с загрузкой 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) в Си). И в-третьих, желательно, чтобы данные, предшествующие массиву и следующие за ним, заметно отличались по внешнему виду, что совершенно не обязательно выполняется на практике. Более того, программисты, исходя из стремления к порядку в исходных текстах, очень часто располагают однотипные массивы последовательно, чем отнюдь не облегчают жизнь крэкерам.
Как видите, оба способа обладают существенными недостатками, сильно зависят от «человеческого фактора», не гарантируют стопроцентного результата (впрочем, в крэкинге результат вообще никто гарантировать не может), а область их действия – ограничена. Что еще можно сделать, чтобы поднять эффективность этих техник? Ответ прост – необходим синтез обоих методов! Результаты, полученные при помощи второго метода, могут быть проверены поиском указателя на первый элемент в коде программы. И наоборот, если Вы нашли возможную ссылку на начало массива, посмотрите, как выглядит тот участок программы, на который указывает эта ссылка, похож ли он на начало массива, или необходимо внести поправки в свои рассуждения. Вообще, крэкеру (да и не только крэкеру) стоит взять на вооружение следующий принцип: если результат, полученный одним способом, может быть проверен другим способом, эту нужно выполнить проверку. В конечном итоге следование этому принципу позволит Вам избежать ошибок, и из-под Вашей руки выйдет гораздо меньше недопатченных и криво взломанных программ.
Поэтому я сейчас поведаю Вам про третий метод исследования массивов, немного варварский, но весьма результативный. Этот метод позволяет не только обнаружить начало массива, но и выяснить действительные размеры каждого элемента структуры и определить, какой из элементов открывает определение этой структуры. Суть метода проста: добравшись до одного из элементов массива, Вы начинаете при помощи шестнадцатеричного редактора изменять отдельные байты и наблюдать, как эти изменения скажутся на работе программы. При известной наблюдательности этот метод позволяет быстро и без особых усилий понять внутреннюю логику организации данных; впрочем, изменение кода программы (значений регистров, содержимого памяти) с целью «посмотреть, что будет, если…» - один из наиболее популярных подходов в крэкинге, доказавший на практике свою эффективность.
Все, что я рассказал выше об изучении массивов, не следует воспринимать как единожды данное откровение, в котором нельзя ничего добавить, отнять или изменить. Напротив, предложенные методы можно и нужно комбинировать, адаптировать под конкретные задачи, которые будут вставать перед Вами. И если я чего-то не упомянул или о чем-то умолчал, то исключительно по той причине, что невозможно описать в одной короткой главе все возможные случаи, с которыми Вы можете столкнуться в процессе изучения программ. А потому повторю еще раз: данный материал – это не «инструкция по крэкингу», но, прежде всего, базовый материал для самостоятельных рассуждений, выводов и экспериментов.
Ну вот, теперь знакомство с приемами поиска данных в исполняемых файлах программ в основном закончено. Осталось лишь изучить специфические техники поиска данных «на лету», то есть в работающих программах, а также некоторые особо экзотические приемы извлечения данных из программ.