Теоретические основы крэкинга

Вид материалаДокументы
Куда попадают данные
Подобный материал:
1   2   3   4   5   6   7   8   9   ...   14
адрес_элемента = начальный_адрес_массива + размер_элемента * (номер_элемента - номер_первого_элемента). Как мы видим, в этой формуле в явном виде присутствует начальный адрес массива, который мы и ищем в программе.


Кроме того, если этот массив передается в качестве аргумента в какую-либо процедуру или функцию, то весьма вероятно, что в программе встретится команда 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) в Си). И в-третьих, желательно, чтобы данные, предшествующие массиву и следующие за ним, заметно отличались по внешнему виду, что совершенно не обязательно выполняется на практике. Более того, программисты, исходя из стремления к порядку в исходных текстах, очень часто располагают однотипные массивы последовательно, чем отнюдь не облегчают жизнь крэкерам.


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


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


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


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

Глава 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-й записи в массиве. Поскольку текстовые данные отличить от числовых несложно даже по внешнему виду, в данном конкретном примере никаких сложностей с извлечением из файла элементов массива скорее всего не возникнет. Но представим, что текстовых полей – несколько, а сохраняемые в этих полях значения – внешне очень похожи. И что каждая из структур в массиве содержит подструктуры, сохраняемые в том же самом файле подобным же образом. Задача расшифровки внутреннего формата файла уже не кажется такой тривиальной, не правда ли?

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

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


Во-первых, Вам придется озадачиться поиском подходящего инструмента. Классические дамперы из крэкерского арсенала Вам не помогут, поскольку они предназначены для снятия дампа