Теоретические основы крэкинга: Глава 5. Структуры, массивы, ссылки.

Дата публикации 25 апр 2004

Теоретические основы крэкинга: Глава 5. Структуры, массивы, ссылки. — Архив WASM.RU

Читая предыдущую главу, Вы, наверное, были неприятно удивлены тем, с какими сложностями можно столкнуться, решая такую нехитрую задачу, как поиск в программе нужной константы. И, возможно, подумали, что если простые числовые и символьные константы способны создать столько проблем, то каково же будет разбираться в составных типах данных! Поспешу Вас успокоить - разобраться в структурированных данных обычно не слишком сложно именно в силу их регулярной структуры. К тому же, в этой главе мы не будем рассматривать такие достаточно сложные вещи, как тонкости представления текста в различных кодировках, и размышлять над глобальными проблемами машинного округления - это уже пройденный этап, и, я думаю, Вы сможете эффективно применить эти знания без дополнительных подсказок. Впрочем, простота теоретической части обсуждаемой нами темы компенсируется сложностями при практическом применении предлагаемых Вам знаний.

Сразу отмечу, что эта глава посвящена не столько непосредственному взлому программ, сколько техникам "вытаскивания" структурированной информации, упрятанной в недрах исполняемых файлов. Впрочем, техники, описанные в этой главе, с некоторыми модификациями могут быть применены и к другим типам файлов, хранящих структурированные данные. Это может быть полезно, к примеру, для тех, кому потребуется извлечь из программы какие-либо данные или "расколоть" базу данных заранее неизвестного формата.

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

Теперь перейдем к практическим аспектам использования указателей в программах. Здесь работают три очень простых правила:

  • к любой константе длиннее 4 байт обращаются по ее адресу
  • к любой константе составного или строкового типа обращаются по ее адресу
  • в коде любой программы адрес начала статической переменной (т.е. переменной, память под которую выделяется в момент запуска программы) явно указан как минимум один раз независимо от типа этой переменной

Это, конечно, очень широкое обобщение (и не всегда верное - достаточно вспомнить пример с загрузкой 10-байтной вещественной константы в Delphi из предыдущей главы), но, в общем, современные компиляторы действительно используют ссылки весьма широко. И если в программе по адресу X находится строковая константа 'My text', то почти наверняка где-то в коде программы найдется команда push X, mov eax,X или что-либо подобное. Именно на этом факте наличия "указателей на все, к чему можно обратиться по указателю", а также на отсутствии путаницы с сегментами и смещениями и основана "разумность" дизассемблеров под Win32, поначалу сильно удивляющая тех, кто успел привыкнуть к маловразумительным листингам дизассемблированных 16-разрядных программ для MS DOS.

Хотя при наличии очень большого желания (и еще большего терпения, чтобы это отладить) все-таки можно обращаться к данным, не используя явные ссылки на эти данные. Не верите? Тогда попробуйте разобраться в следующем коде для платформы Win32 и найти в нем хоть одну ссылку на выводимые в MessageBox'е строки:

Код (Text):
  1.  
  2. push 0
  3. call $+1Ah
  4. db 'Code sample',0,'Any text',0
  5. mov eax,[esp]
  6. add eax,0Ch
  7. push eax
  8. push 0
  9. call MessageBoxA

Такие могучие коды начисто "срывают башню" даже IDA Pro, который оказывается совершенно неспособен более или менее логично дизассемблировать это нечто. Вот уж воистину "горе от ума" - древний, по нынешним меркам, W32DAsm дизассемблировал этот код гораздо адекватнее. Разумеется, знаменитому дизассемблеру при желании можно (и даже не очень сложно) объяснить, где в действительности находятся данные, а где - код, который к этим данным обращается, но чтобы это сделать придется сначала разобраться в приведенном коде самому. Понятно, что, написать целую программу таким "высоким штилем" вряд ли у кого-то получится (да и языки высокого уровня мало приспособлены к подобным экзерсисам), но определение адреса блока данных при помощи пары команд

Код (Text):
  1.  
  2. call $+5
  3. 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 (в Паскале структуры принято называть записями):

Код (Text):
  1.  
  2. type my_record=record
  3.  a:byte;
  4.  b:word;
  5.  c:dword;
  6. 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 (надеюсь, Вы внимательно читали предыдущую главу и уже догадались, зачем я присвоил третьему полю столь странное значение). После компиляции мы получим следующий код:

Код (Text):
  1.  
  2. :00452100 C605005C450001          mov byte ptr [00455C00], 01
  3. :00452107 66C705025C45000200      mov word ptr [00455C02], 0002
  4. :00452110 C705045C45004E61BC00    mov dword ptr [00455C04], ABCDEF10

Возникает закономерный вопрос: чем так плох адрес 455С01, что по этому адресу компилятор "не захотел" хранить данные. Ответ на этот вопрос лежит в недрах архитектуры x86. С незапамятных времен процессоры x86 выбирали данные, лежащие по четным адресам, немного быстрее, по сравнению с такими же данными, лежащие по нечетным адресам. Чуть позже процессорам стали "нравиться" адреса, кратные четырем. С совершенствованием процессоров список "хороших" адресов продолжал расширяться, появились "особенные" последовательности команд, которые выполнялись быстрее "обыкновенных" и в результате предельная оптимизация программ стала занятие настолько сложным, что стало проще доверить ее компилятору. Для достижения максимальной производительности программисты старались размещать часто используемые данные именно по таким "хорошим" адресам. А чтобы программисту не приходилось раскладывать данные по нужным адресам вручную, в компиляторы была введена такая опция, как "выравнивание данных". Эта опция заставляет компилятор принудительно размещать данные по адресам, кратным величине выравнивания. В нашем случае данные выровнены по четным адресам, поэтому ячейка с адресом 455С01, находящаяся между однобайтным полем a и двухбайтным b, осталась не у дел. Однако если программисту требуется хранить в памяти достаточно большое количество записей, потери памяти из-за выравнивания могут оказаться неприемлемо большими, и в таких случаях выравнивание либо отключают вообще, либо при помощи служебных слов "объясняют" компилятору, к структурам каких типов выравнивание применять не надо.

Использование выравнивания в программах дает один интересный побочный эффект, облегчающий изучение и извлечение данных, хранящихся в программах. Хотя формально значение байтов, находящихся в "дырках" между полями константы-структуры, не определено, на практике все известные мне компиляторы записывают туда нули (что интересно, эти "дырки" при желании тоже можно использовать для хранения данных). В результате достаточно длинный массив таких структур довольно легко определить среди прочих данных "на глаз". Лучше всего для таких целей подходят редакторы, позволяющие при просмотре изменять количество байт, отображаемых в одной строке и выделять цветом характерные последовательности байт (в частности, таким свойством обладает Hex Workshop 4) - цветные пятна образуют характерный узор, который Вы начнете легко замечать после минимальной практики. Тем более, что если отформатировать дамп так, чтобы в строке умещалось столько байт, сколько занимает одна структура, "дырки" выстроятся в вертикальную полосу. Чтобы Вам было понятнее, о чем я говорю, приведу пример массива записей и продемонстрирую, какими путями можно попытаться определить размер и назначение полей структур. Вот код, присутствующий в одной из старых версий моей программы InqSoft Sign 0f Misery:

Код (Text):
  1.  
  2. 28AB5100 74AB5100 8CAB5100 03030D0D 00000000 01002E04 09000000 80000000
  3. B0AB5100 D0AB5100 00000000 03000F00 00000000 0100CE04 08000000 81000000
  4. ECAB5100 D0AB5100 00000000 03000F00 00000000 0100C404 08000000 A2000000
  5. 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-строки:

Код (Text):
  1.  
  2. 0051AB28: "Ожидать появления окна с указанным текстом в заголовке и классом"
  3. 0051AB74: "Имя класса окна"
  4. 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 вместо ссылок на истинное начало массива для экономии одной операции вычитания может использоваться вычисленный таким образом адрес "нулевого" элемента, не существующего в действительности. Подобные приемы иногда используются и при программировании на ассемблере, когда для ускорения программы вместо пары команд

Код (Text):
  1.  
  2. dec ebx
  3. mov eax, [OFFSET my_arr+ebx*4]

используется единственная команда mov eax, [(OFFSET my_arr-4)+ebx*4], в которой значение (OFFSET my_arr-4) вычисляется на этапе компиляции.

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

Принцип, заложенный в основу этого метода, очень прост. Хранящиеся в массиве данные носят осмысленный характер, а при просмотре в шестнадцатеричном редакторе структурированность этих данных довольно легко заметна. А потому, если Вам известна точка, заведомо находящаяся внутри массива, двигаясь от этой точки вверх или вниз, Вы сможете обнаружить начало или конец массива по изменению внешнего вида данных.

Если посмотреть на приведенный в качестве примера кусок массива, регулярность данных в этом массиве видна невооруженным глазом: нолики идут под ноликами, единички - под единичками, указатели - под указателями, ну и так далее. Если бы я привел дамп всего массива целиком (он довольно объемный), Вы бы смогли убедиться, что внешний вид всех остальных записей массива очень похож на эти четыре строки. Однако как только Вы дойдете до начала или конца массива, внешний вид данных, скорее всего, изменится - ведь перед массивом и после него хранятся совсем другие данные с другой структурой (хотя надо отметить, что если подряд идут да массива с однотипными данными, то граница межу ними может быть не столь явной).

Код (Text):
  1.  
  2. 80000000 00800000 20000000 00400000 40000000 78985100 88985100 98985100
  3. A8985100 B8985100 C8985100 E4985100 00995100 1C995100 30995100 68000000
  4. C49B5100 E09B5100 00000000 03000E00 01000000 00006400 00000000 97000000
  5. 009C5100 E09B5100 3C9C5100 03030E0D 01000000 00006E00 00000000 6B000000

Внимательно посмотрев на приведенный кусок, Вы можете видеть, что массив начинается с восьмой тетрады второй строки. Почему? Во-первых, достаточно очевидно, что первые две строки заметно отличаются от третьей и четвертой, следовательно, границу между массивами нужно искать именно во второй либо в третьей строке. Первая и вторая тетрада второй и третьей строки похожи на указатели на текстовые строки (хотя, если копнуть чуть глубже, можно обнаружить, что только в третьей строке эти ссылки действительно указывают на названия команд). А вот четвертая, пятая, шестая и седьмая тетрады также являются указателями и внешне сильно отличаются от соответствующих тетрад третьей и четвертой строки. Очевиден вывод: все данные по седьмую тетраду второй строки включительно не принадлежат массиву. Осталось разобраться с восьмой тетрадой: она ничем не выделяется на фоне последующих двух, поэтому мы с чистой совестью можем отнести ее к нашему массиву и сказать, что массив начинается именно с нее. Дальше, если проанализировать весь массив, выяснится, что первое поле - уникально для каждого элемента массива, и его значение не превышает 255 (то есть первый элемент структуры - байт). Логично предположить, что это и есть код команды, который мы и искали.

Конец массива отыскивается аналогичным способом; поиск конца массива, пожалуй, даже проще, поскольку очевидно, что размер массива в целом должен быть кратен размеру одного элемента этого массива.

Увы, и этот метод не свободен от недостатков. Во-первых, для успешного использования этого метода необходимо, чтобы элемент массива можно было легко отличить от данных, не имеющих к массиву никакого отношения. Однако так бывает далеко не всегда, например, таблица констант для реализации CRC по алгоритму WiseByte выглядит как набор из 256 псевдослучайных чисел, не имеющих каких-либо характерных признаков, видимых невооруженным глазом. Во-вторых, проблему представляют случаи, когда в одном массиве хранятся разнородные данные. Возможность хранения разнородных данных в структуре предусматривается некоторыми языками программирования ("структуры с вариантами" в Паскале и объединения (union) в Си). И в-третьих, желательно, чтобы данные, предшествующие массиву и следующие за ним, заметно отличались по внешнему виду, что совершенно не обязательно выполняется на практике. Более того, программисты, исходя из стремления к порядку в исходных текстах, очень часто располагают однотипные массивы последовательно, чем отнюдь не облегчают жизнь крэкерам.

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

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

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

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


0 1.511
archive

archive
New Member

Регистрация:
27 фев 2017
Публикаций:
532