Теоретические основы крэкинга
Вид материала | Документы |
- Теоретические основы крэкинга, 1953.85kb.
- Методические указания и задания для выполнения домашних контрольных работ, 953.98kb.
- «Теоретические основы налогообложения», 1177.31kb.
- Рабочая программа по дисциплине Теоретические основы электротехники Рекомендуется для, 705.4kb.
- Тематика курсовых работ: по дисциплине «Теоретические основы товароведения и экспертизы», 12.47kb.
- М. А. Теоретические основы товароведения: учебник, 71.17kb.
- Т. Ф. Киселева теоретические основы консервирования учебное пособие, 2450.86kb.
- Теоретические вопросы дисциплины «Теоретические основы электротехники»,, 28.22kb.
- Программа элективного курса «Теоретические основы органической химии», 128.29kb.
- Рабочая программа По дисциплине «Сетевые технологии» По специальности 230102. 65 Автоматизированные, 210.65kb.
Читая предыдущую главу, Вы, наверное, были неприятно удивлены тем, с какими сложностями можно столкнуться, решая такую нехитрую задачу, как поиск в программе нужной константы. И, возможно, подумали, что если простые числовые и символьные константы способны создать столько проблем, то каково же будет разбираться в составных типах данных! Поспешу Вас успокоить – разобраться в структурированных данных обычно не слишком сложно именно в силу их регулярной структуры. К тому же, в этой главе мы не будем рассматривать такие достаточно сложные вещи, как тонкости представления текста в различных кодировках, и размышлять над глобальными проблемами машинного округления – это уже пройденный этап, и, я думаю, Вы сможете эффективно применить эти знания без дополнительных подсказок. Впрочем, простота теоретической части обсуждаемой нами темы компенсируется сложностями при практическом применении предлагаемых Вам знаний.
Сразу отмечу, что эта глава посвящена не столько непосредственному взлому программ, сколько техникам «вытаскивания» структурированной информации, упрятанной в недрах исполняемых файлов. Впрочем, техники, описанные в этой главе, с некоторыми модификациями могут быть применены и к другим типам файлов, хранящих структурированные данные. Это может быть полезно, к примеру, для тех, кому потребуется извлечь из программы какие-либо данные или «расколоть» базу данных заранее неизвестного формата.
Рассказывая о нецелочисленных константах, я упоминал о том, что обращения к таким константам производятся по указателю, то есть в команде, загружающей действительное число в регистр сопроцессора, в явном виде присутствует адрес загружаемого числа. Однако доступ к данным по указателю на них характерен не только чисел с плавающей точкой (на некоторых платформах числа с плавающей точкой отлично умещаются в регистрах общего назначения), но и для строк, структур и массивов. Теоретически, небольшую структуру или короткий массив еще можно было бы попытаться разместить в регистрах общего назначения (собственно, при предельной оптимизации кода иногда так и поступают), но большинство реальных данных требуют для своего хранения гораздо больше места, чем могут предоставить регистры процессора. Наиболее простым и быстрым способом работы с такими объемными данными является хранение этих данных в оперативной памяти и обращение к ним через посредство указателей.
Теперь перейдем к практическим аспектам использования указателей в программах. Здесь работают три очень простых правила:
- к любой константе длиннее 4 байт обращаются по ее адресу
- к любой константе составного или строкового типа обращаются по ее адресу
- в коде любой программы адрес начала статической переменной (т.е. переменной, память под которую выделяется в момент запуска программы) явно указан как минимум один раз независимо от типа этой переменной
Это, конечно, очень широкое обобщение (и не всегда верное – достаточно вспомнить пример с загрузкой 10-байтной вещественной константы в Delphi из предыдущей главы), но, в общем, современные компиляторы действительно используют ссылки весьма широко. И если в программе по адресу X находится строковая константа ‘My text’, то почти наверняка где-то в коде программы найдется команда push X, mov eax,X или что-либо подобное. Именно на этом факте наличия «указателей на все, к чему можно обратиться по указателю», а также на отсутствии путаницы с сегментами и смещениями и основана «разумность» дизассемблеров под Win32, поначалу сильно удивляющая тех, кто успел привыкнуть к маловразумительным листингам дизассемблированных 16-разрядных программ для MS DOS.
Хотя при наличии очень большого желания (и еще большего терпения, чтобы это отладить) все-таки можно обращаться к данным, не используя явные ссылки на эти данные. Не верите? Тогда попробуйте разобраться в следующем коде для платформы Win32 и найти в нем хоть одну ссылку на выводимые в MessageBox’е строки:
push 0
call $+1Ah
db 'Code sample',0,'Any text',0
mov eax,[esp]
add eax,0Ch
push eax
push 0
call MessageBoxA
Такие могучие коды начисто «срывают башню» даже IDA Pro, который оказывается совершенно неспособен более или менее логично дизассемблировать это нечто. Вот уж воистину «горе от ума» - древний, по нынешним меркам, W32DAsm дизассемблировал этот код гораздо адекватнее. Разумеется, знаменитому дизассемблеру при желании можно (и даже не очень сложно) объяснить, где в действительности находятся данные, а где – код, который к этим данным обращается, но чтобы это сделать придется сначала разобраться в приведенном коде самому. Понятно, что, написать целую программу таким «высоким штилем» вряд ли у кого-то получится (да и языки высокого уровня мало приспособлены к подобным экзерсисам), но определение адреса блока данных при помощи пары команд
call $+5
pop eax
в защитных процедурах (в частности – в процедурах расшифровки кусков кода) встречается довольно часто.
Другим важным моментом, который необходимо помнить при «расшифровке» структур и массивов, хранимых в коде программ, является то, что популярные компиляторы не поддерживают использование структур переменного размера. То есть длина структуры жестко фиксирована и постоянна для всех структур одного и того же типа: если переменная A типа MY_TYPE занимает 100 байт, то переменная B того же типа также будет занимать 100 байт независимо от того, какие данные в ней хранятся. Возникает вполне естественный вопрос: а как же тогда хранятся строки или динамические массивы, размер которых заранее неизвестен? В действительности, в современных компиляторах строки не хранятся непосредственно внутри структур. Вместо этого хранятся лишь указатели на статически или динамически выделенные блоки памяти, а непосредственно текст строки размещается именно в этих блоках. Этим и объясняется смущающий начинающих программистов на Delphi и С++ Builder эффект, когда при попытке определить размер строки при помощи SIZEOF получается, что любая строка занимает 4 байта, а при записи структуры, содержащей поля строкового типа, в файл, вместо текстовых строк появляется странного вида «мусор» длиной в четыре байта. Исключением из этого правила являются только старые паскалевские строки фиксированной длины и массивы символов (char) в Си, но оба эти типа в настоящее время употребляются довольно редко. Кроме того, ссылки на данные вместо самих данных также используются для хранения объектов (тех, которые «экземпляры классов») и динамических массивов.
Если Вы захотите, Вы можете набросать небольшую программку для поиска всех возможных ссылок в коде программ. Базовая идея проста: код программы и статические переменные, как правило, имеют весьма небольшой объем по сравнению с максимально возможным размером адресного пространства в Win32 (4 гигабайта, если не вдаваться в тонкости устройства Windows). А потому мы можем с высокой вероятностью считать ссылками на код или данные все 32-разрядные числа в программе, которые попадают в промежутки адресов, занятые программой или данными. Чтобы проверить все это на практике, так сказать, «потрогать руками», нужна программа, извлекающая из исполняемого файла все четырехбайтные значения, которые теоретически могут быть адресами. Если Вы уже попробовали написать утилиту для поиска нецелочисленных данных и проверили ее в действии, проблем с программированием у Вас не возникнет. Самое сложное – извлечь из заголовка PE-файла адреса начала каждой секции в памяти, размеры этих секций и смещения начала каждой секции в файле. Если Вы не хотите сразу же погружаться в изучение структуры PE-заголовка (а рано или поздно этим все же придется заниматься), на первых порах Вы можете ограничиться ручным вводом этих данных.
А теперь разберемся, что Вы собственно написали. А написали Вы ни что иное, как одну из частей обыкновенного дизассемблера, занимающуюся поиском ссылок в коде программ. Разумеется, настоящие дизассемблеры используют для поиска ссылок гораздо более сложные алгоритмы, но нам сейчас нужен как раз такой простейший инструмент. Разумеется, поиск вообще всех возможных ссылок на данные – само по себе занятие малополезное, но если немного поразмыслить… Если немного поразмыслить, у такой программы появляется довольно неожиданное применение: поиск начальных адресов структур и массивов по одному или нескольким известным полям (элементам). Так что не откладывайте эту программу в дальний угол винчестера – она нам очень скоро пригодится.
Итак, предположим, что Вы знаете о структурах и массивах все, что положено знать начинающему программисту, а именно: что они есть, что в них можно хранить данные, и, самое главное, как в программах обращаются к отдельным элементам этих структур и массивов. Более того, Вы даже знаете, что массивы и структуры можно комбинировать весьма удивительными и изящными способами: создавать массивы массивов (они же двухмерные массивы), массивы структур, массивы массивов структур, ну и так далее. Абсолютное большинство компилирующих языков при хранении данных структурированных типов придерживаются принципа: поля и элементы массивов хранятся в памяти в том порядке, в каком они определены в исходных текстах программы. Ну и, разумеется, порядок полей неизменен для всех данных одного типа. Само по себе это, конечно, мало что дает – ведь исходников-то у нас нет, но тут в игру вступает психология. Да-да, программисты - тоже люди, а изучение их психологии, даже поверхностное, иногда помогает лучше понять творения программистов (то бишь программы). В частности, многим программистам свойственно стремление к логике и элементарному порядку в исходных текстах. Например, если в ходе исследования неких данных Вам удалось установить, что первое и второе поле структуры – это указатели на фамилию и имя, то третье поле структуры скорее всего окажется указателем на отчество, а не закодированным размером обуви или цветом волос. Или, если речь идет о заголовке архива, за именем файла наверняка последует смещение сжатого файла в архиве (плюс-минус некоторая константа), размер этого файла в сжатом виде и контрольная сумма. Причем именно в таком порядке – это традиция, которую программисты нарушать не любят. Если Вы кроме крэкинга занимаетесь программированием, поразмыслите о том, как бы Вы разместили информацию, будь Вы на месте автора программы – и, возможно, это будет наиболее короткий путь к пониманию структуры данных. Именно понимание программистских традиций, «неписанных законов» позволили исследователям недр Windows NT без особых сложностей разобраться с параметрами вызовов Native API – им потребовалось лишь изучить общедоступную документацию, понять, как мыслят программисты в Microsoft и немного повозиться с отладчиком.
Но вернемся к нашим структурам. Проведем небольшой эксперимент: возьмем все тот же Delphi и определим пользовательский тип my_record (в Паскале структуры принято называть записями):
type my_record=record
a:byte;
b:word;
c:dword;
end;
А теперь попробуем подсчитать, какова длина такой записи в байтах. Если просто сложить длины полей, входящих в запись, должно получиться 1+2+4=7 байт. Но в действительности все обстоит несколько иначе: sizeof (my_record)=8! Чтобы выяснить, почему так случилось, определим в программе переменную my_var типа my_record и попытаемся присвоить полям этой переменной значения: my_var.a:=1; my_var.b:=2; my_var.c:=$ABCDEF10 (надеюсь, Вы внимательно читали предыдущую главу и уже догадались, зачем я присвоил третьему полю столь странное значение). После компиляции мы получим следующий код:
:00452100 C605005C450001 mov byte ptr [00455C00], 01
:00452107 66C705025C45000200 mov word ptr [00455C02], 0002
:00452110 C705045C45004E61BC00 mov dword ptr [00455C04], ABCDEF10
Возникает закономерный вопрос: чем так плох адрес 455С01, что по этому адресу компилятор «не захотел» хранить данные. Ответ на этот вопрос лежит в недрах архитектуры x86. С незапамятных времен процессоры x86 выбирали данные, лежащие по четным адресам, немного быстрее, по сравнению с такими же данными, лежащие по нечетным адресам. Чуть позже процессорам стали «нравиться» адреса, кратные четырем. С совершенствованием процессоров список «хороших» адресов продолжал расширяться, появились «особенные» последовательности команд, которые выполнялись быстрее «обыкновенных» и в результате предельная оптимизация программ стала занятие настолько сложным, что стало проще доверить ее компилятору. Для достижения максимальной производительности программисты старались размещать часто используемые данные именно по таким «хорошим» адресам. А чтобы программисту не приходилось раскладывать данные по нужным адресам вручную, в компиляторы была введена такая опция, как «выравнивание данных». Эта опция заставляет компилятор принудительно размещать данные по адресам, кратным величине выравнивания. В нашем случае данные выровнены по четным адресам, поэтому ячейка с адресом 455С01, находящаяся между однобайтным полем a и двухбайтным b, осталась не у дел. Однако если программисту требуется хранить в памяти достаточно большое количество записей, потери памяти из-за выравнивания могут оказаться неприемлемо большими, и в таких случаях выравнивание либо отключают вообще, либо при помощи служебных слов «объясняют» компилятору, к структурам каких типов выравнивание применять не надо.
Использование выравнивания в программах дает один интересный побочный эффект, облегчающий изучение и извлечение данных, хранящихся в программах. Хотя формально значение байтов, находящихся в «дырках» между полями константы-структуры, не определено, на практике все известные мне компиляторы записывают туда нули (что интересно, эти «дырки» при желании тоже можно использовать для хранения данных). В результате достаточно длинный массив таких структур довольно легко определить среди прочих данных «на глаз». Лучше всего для таких целей подходят редакторы, позволяющие при просмотре изменять количество байт, отображаемых в одной строке и выделять цветом характерные последовательности байт (в частности, таким свойством обладает Hex Workshop 4) – цветные пятна образуют характерный узор, который Вы начнете легко замечать после минимальной практики. Тем более, что если отформатировать дамп так, чтобы в строке умещалось столько байт, сколько занимает одна структура, «дырки» выстроятся в вертикальную полосу. Чтобы Вам было понятнее, о чем я говорю, приведу пример массива записей и продемонстрирую, какими путями можно попытаться определить размер и назначение полей структур. Вот код, присутствующий в одной из старых версий моей программы InqSoft Sign 0f Misery:
28AB5100 74AB5100 8CAB5100 03030D0D 00000000 01002E04 09000000 80000000
B0AB5100 D0AB5100 00000000 03000F00 00000000 0100CE04 08000000 81000000
ECAB5100 D0AB5100 00000000 03000F00 00000000 0100C404 08000000 A2000000
0CAC5100 30AC5100 48AC5100 03030D0D 00000000 0100A604 08000000 A3000000
Как видите, факт структурированности данных заметен невооруженным глазом, хотя мы ничего не знаем о том, какие именно данные хранятся в этом массиве. Достаточно очевидно, что размер одного элемента массива равен тридцати двум байтам. Надо отметить, здесь нам очень повезло в том, что размер структуры совпал с числом байт, отображаемых в одной строке. Впрочем, многие шестнадцатеричные редакторы позволяют менять этот параметр и группировать байты произвольным образом (т.е. не обязательно по 4, как это сделано в примере). Попробуем рассуждать логически. Первым делом заглянем в PE-заголовок файла программы и посмотрим начальные адреса и длины (в терминологии PE Explorer’а - Virtual address и Size of Raw Data соответственно) секций. Просуммировав эти характеристики, в первом приближении мы можем считать, что наша программа после загрузки занимает в памяти адреса с 410000h по 6005FFh (хотя, в общем случае, между секциями в памяти могут быть «дыры»). Поэтому числа, попадающие в этот промежуток, с большой вероятностью являются указателями на данные.
Внимательно посмотрев на первый, второй и третий столбец, Вы можете заметить, что в этих столбцах как раз находятся числа из промежутка 410000h..6005FFh, т.е. это потенциальные указатели на данные. Нули, встречающиеся в третьем столбце - это «пустые» указатели; такие указатели широко известны в языках программирования под различными именами. В C/C++ такие указатели обозначаются как NULL, в Паскале/Delphi - nil. Попробуем посмотреть, что находится по адресам из первых трех столбцов первой строки. А находятся по этим адресам ASCIIZ-строки:
0051AB28: «Ожидать появления окна с указанным текстом в заголовке и классом»
0051AB74: «Имя класса окна»
0051AB8C: «Текст в заголовке окна»
Если проверить остальные указатели, мы также обнаружим по соответствующим адресам текстовые строки. Отлично! Теперь нам известны длина и тип трех полей структуры. Теперь обратите внимание на шестую тетраду. Числа 042E0001, 04CE0001, 04C40001 мало похожи на осмысленные данные, что в шестнадцатеричной системе, что в десятичной. Но вот если четырехбайтные последовательности интерпретировать не как DWORD, а как два идущих подряд WORD’а, то данные начинают выглядеть менее странно: (1,1070), (1,1230), (1,1220). Правда, мы не можем быть уверенными (как я уже говорил, такова обратная сторона метода, базирующегося на наблюдениях и предположениях), что первые два байта – это действительно одно поле типа DWORD, а не два поля типа BYTE – имеющаяся выборка слишком мала. Чтобы проверить это, необходимо исследовать код программы, который обращается к этому массиву. Но это - тема следующей главы.
Если бы я привел более длинный кусок массива, было бы более очевидно, что четвертая тетрада на самом деле состоит из четырех полей типа BYTE, причем первые два из них принимают значения от нуля до пяти. А также то, что если первый или второй байт четвертой тетрады равен нулю, то вторая или третья тетрада соответственно будет хранить пустой указатель. В общем, определение типов полей по их значениям и структуре массива – занятие, требующее прежде всего наблюдательности и логического мышления.
А пока разберем некоторые тонкости, о которых я умалчивал до этого момента. Вы, наверняка заметили, что, приведя в качестве примера некий кусок кода, я просто сказал «это массив структур», не приведя никаких объяснений, каким образом я обнаружил этот код в программе. На практике задача обычно ставится несколько иначе: по некоторым известным данным необходимо найти в программе область, где эти данные хранятся в структурированном виде, разобраться, какие еще данные, помимо уже известных, содержит эта структура, и затем извлечь то, что нам было неизвестно.
То есть, когда мы хотим извлечь некую информацию из программы, мы ищем не «то, не знаю что», а имеем некоторое представление, что нам нужно и как оно может выглядеть. Поэтому если Вам точно (или даже приблизительно) известно хотя бы одно из значений, входящих в массив, Вы можете приступить к поиску этого значения внутри программы при помощи методов, описанных в предыдущей главе. И если поиск окажется успешным, Вы будете знать расположение одного из элементов массива.
Кроме того, я продемонстрировал расшифровку отдельных полей, но не сказал ни слова о том, с какого из этих полей начинается описание структуры. Иными словами, мы все еще не знаем, находится ли ссылка на строку по адресу 0051AB28 в начале структуры, в середине или же вообще является последним полем записи, а следующая за ней ссылка на адрес 0051AB74 относится уже к следующему элементу массива. Если вспомнить «психологию программиста», о которой я говорил выше, и проанализировать содержимое строк, то принадлежность всех трех ссылок к одной и той же записи достаточно очевидна, но мы не будем упрощать себе задачу и попробуем честно добраться до приведенного массива. К сожалению, для полного представления о решаемой задаче необходима сама программа, привести которую в данной главе невозможно по причине ее большого объема, поэтому мне придется ограничиться только демонстрацией наиболее важных ее кусков.
Итак: у нас есть программа, которая состоит из интерпретатора байт-кода и оболочки, позволяющей писать скрипты, которые затем транслируются в байт-код. Внутри оболочки содержится массив, описывающий команды, используемые в этой программе и байт-коды, соответствующие каждой команде, и нам требуется извлечь из программы список байт-кодов и названий команд, которые этим кодам соответствуют. Сама оболочка запакована при помощи UPX, но ее распаковка никакой сложности не представляет. Названия команд известны каждому запустившему программу, поскольку полный список команд отображается в оболочке.
Возьмем любую команду, например «Ожидать появления окна с указанным текстом в заголовке и классом» и попробуем найти этот текст в коде программы. Никаких особых ухищрений для этого не потребуется, при помощи функции поиска в HexWorkshop находим, что искомая строка встречается в файле в единственном экземпляре по смещению 1154856 байт от начала файла. Далее, при помощи любого Offset->RVA конвертера (утилиты, преобразующей смещения в файле в относительные виртуальные адреса (RVA)) определяем, по какому адресу будет размещен 1154856-й байт программы после загрузки этой программы. Получаем, что эта строка в памяти располагается начиная с адреса 51AB28h. Теперь вспомните то, что говорилось о методах хранения строк внутри структур, и Вам станут очевидны наши дальнейшие действия.
Следующий этап будет заключаться в поиске по всему коду программы ссылок на нужную строку, то есть 32-битной константы 51AB28h. Такая константа нашлась сразу же, причем в единственном экземпляре по смещению 1229C0h от начала файла. 128-байтный блок, начинающийся с этой константы, я и привел в качестве примера несколькими абзацами выше. Теперь Вы имеете представление, при помощи каких приемов можно добраться до нужного массива. Как видите, здесь все достаточно просто.
Теперь нам надо выяснить, с какого байта начинается какой-либо элемент приведенного массива. На практике обычно проще всего найти адрес первого элемента массива (или нулевого, если использовать нумерацию, принятую в Ассемблере или Си). Сделать это можно двумя способами: поиском ссылок на начало массива либо анализом самих данных с целью поиска верхней и/или нижней границы массива.
Первый способ базируется на том, что в программе почти наверняка найдется явная ссылка на первый элемент массива, и что адрес этого элемента больше либо равен адресу одного из известных нам элементов, входящих в массив. В применении к нашему примеру это означает, что если смещение одного из найденных нами элементов структуры равно 1229C0h (RVA для этого смещения равно 522BC0h), то начало массива, очевидно, находится не ниже этого адреса. Следовательно, если выбрать из программы все константы меньше либо равные 522BC0h (а для этого нам и нужна соответствующая программа, о которой я уже упоминал), среди них окажется и ссылка на первый элемент массива. При этом, если рассортировать найденные ссылки по убыванию их величин, велика вероятность, что ссылка на начало массива окажется в числе первых.
Продемонстрирую это примером. Допустим, Вы выяснили, что ячейка по адресу 450080h входит в состав массива. Выбрав все ссылки, указывающие на адреса не ниже 450080h, Вы получили следующий набор: 450000h, 49FFD0h, 49FCD4, 49FCD0h и так далее. Из всех найденных Вами адресов теоретически наиболее вероятным адресом будет являться 450000h. Почему? Причина в том, что информация из длинных предварительно инициализированных массивов обычно (но не всегда!) считывается при помощи конструкций вида my_arr[i], где значение i явно не указано и на этапе компиляции определено быть не может. И потому, чтобы обратиться к i-му элементу массива, в общем случае программа должна вычислить адрес этого элемента по формуле