Эта статья продолжает идею предыдущей "Как у меня получилось взломать и распаковать ресурсы старой игры для PSX" здесь я также попытаюсь с точки зрения "новичка в реверс-инжиниринге" описать ход мыслей и действий с помощью которых мне удалось "с нуля" разобраться в устройстве игрового архива.
Я рассчитываю, что она может быть полезна тем, кто боится открывать hex-редактор, думая, что это какая-то хакерская программа, надеюсь мне удастся показать, что даже уровня "продвинутого пользователя" и начальных навыков программирования может хватить для таких вот "вещей".
Часть Первая - Изучаем игру
Итак, перво-наперво нужно собрать информацию по игре, файлы которой мы решили “взломать”.
Представляю вашему вниманию Car Jacker, он же Crazy Drive Away, он же Car Boosting, и местами даже Car Jacker 2.
Игры были сделаны в 2004-2005 годах некой конторой Kozmogames Inc. на движке с пафосным названием e^N-gine.
Такое обилие личин объясняется, скорее всего тем, что в разных странах и у разных издателей игра выходила под разными названиями, видимо для того, чтобы геймеры не могли ничего про нее прочитать до момента покупки, и тем самым не передумали ее покупать.
Сами игры представляют из себя песочницы в стиле GTA с десятком миссий.
Скачать их всех можно по ссылке в интернет-архиве.(если вдруг кто-то захочет сам пользуясь моим руководством повторить взлом)
Приступаем к изучению самой игры, для этого открываем папку в которую она установилась. И вот что предстает нашему взору:
Сама игра весит менее 100мб из которых 90мб занимает файл data.pak, который и представляет собой главный и единственный игровой архив.
В файле config.ini глаза бросается вот такая строчка:
FontFile=textures\GUI\AGLettericaCondensedLight.dds
Тут мы видим относительный путь к файлу со шрифтами, но самого файла в папке с игрой нет, поэтому можно предположить что он хранится где-то внутри data.pak.
Попутно загуглим, что такое dds, оказывается это формат хранения текстур DirectX.
Отложим это пока в голове и откроем data.pak hex-редактором.
В самом начале файла нам предстаёт вот такая картина:
Первые несколько байт с какими-то данными, потом группа нулей, потом опять данные.
С одной стороны выглядит как типичный заголовок, где обычно сначала сигнатура или какое-то смещение, потом нули.
Но вот в чем проблема, первые байты это явно не сигнатура и не смещение, а скорее какая-то абракадабра.
Но отметим для себя, что наличие одинаковых байт “00”, идущих друг за другом говорит нам о том, что весь файл или хотя бы его часть - не сжаты.
На всякий случай, я пропустил архив через "Dragon UnPACKer" и парочку других перспективных универсальных игровых сканеров, на предмет открытых ресурсов, но ожидаемо - безуспешно.
Продолжаем листать data.pak в hex-редакторе дальше, а именно “перематываем” примерно на середину.
Опять огромное количество повторяющихся байтов FF. Если бы архив был сжат, такие сегменты в первую очередь пошли бы под нож, значит опять отмечаем у себя, что сжатия тут нет.
Но тогда почему универсальные игровые распаковщики не обнаружили те же файлы формата .dds, которые упоминались в конфиге?
Листаем дальше, в самый конец файла:
А вот это уже интересно! Последние 160кб файла занимает вот такая структура как на скриншоте.
Блоки равного размера содержащие что-то ОООЧЕНЬ похожее на текст , а остальная часть забита нулями.
Ощущение, что это текст, еще усиливает и то, что если присмотреться, то последовательность у “текста” на скриншоте отличается только четвертым символом с конца. Уж не пути ли это к файлам, с одинаковыми именами и расширениями, отличающимися лишь цифрой в конце?
Тогда получается что \ИИж - это расширение. А помните у нас в файле config.ini был путь к какому-то файлу формата .dds?
Это не может быть совпадением, можно открыть таблицу ANSI(а точнее win-1251) и посмотреть, что нужно сделать с .dds чтобы оно превратилось в \ИИж, но я поступил несколько иначе, создал текстовый файл и открыл его в том же hex-редакторе.
Редактор HxD в одном из окон показывает выделенные байты в разных системах счисления.
И вот что получилось в виде текста и в десятичной системе счисления.
.dds
2E 64 64 73
46 100 100 115
\ИИж
5C C8 C8 E6
92 200 200 230
Я думаю вы уже догадались что произошло. Числовое значение каждого байта было умножено на 2.Небольшой дисклеймер для “настоящих” программистов - не надо кричать сейчас в монитор, АЛЛО ЭТО БИТОВЫЙ СДВИГ и делать “рукалицо”. Мы же тут собрались ради тех, кто в программирование и реверс-инжиниринг пытается зайти с черного входа, так что про битовый сдвиг будет, но чуть позже.
Но есть небольшой нюанс.
Во-первых, умножение на 2 должно приводить к тому, что результат должен делиться на 2, а некоторый байты в файле на 2 не делятся.
Во-вторых, умножение на два чисел начиная со 128 даст результат больше 255, что в свою очередь переполнил максимальное для одного байта значение 255, а у нас тут очевидно каждый байт должен остаться байтом.
Я эту проблему решил на интуитивном уровне, мне было очевидно что единственный способ “утрясти” проблему, сделать следующее: если результат умножения на два больше 255 - отнять от результата 255.
Это во-первых превратит четное после умножения число в нечетное, что не даст ему перекрыть ни одно из чисел до 128 умноженных на два, которые дали четный результат, а во-вторых при обратном декодировании, если вы встретили нечетное число, вы сразу поймете что это результат умножения на два, который превысил 255 и поймете что с ним сделать(добавить 255, а потом разделить на два).
Многие наверное скажут, блин чувак, ну ты офигел, как до такого можно дойти самому, это же какая-то "высшая математика".
Я могу частично с этим согласиться, и поэтому чуть ниже расскажу как можно было дойти до такого же решения без таких вот озарений.
Часть вторая - Пишем расшифровщик
Пришло время создать Proof-of-Concept расшифровщика.
Я свой писал на java. но тут чтобы легче читалось напишу на java-подобном псевдокоде.
File fileI = new File("data.pak");
byte [] fileBytes = Files.readAllBytes(fileI);
for (int i=0;i<fileBytes.length;i++) {
if(!dividesByTwo(fileBytes[i]) fileBytes[i] = (fileBytes[i])+255)/2);
else fileBytes[i]=fileBytes[i])/2;
}
File fileO = new File("data_dec.pak");
Files.write(fileO, fileBytes);
"Код" выше делает следующее: читает файл в массив байтов, потом проходит по этому массиву циклом и если байт не делится на 2, то прибавляет к нему 255 и делит на два, а если делится, то просто делит на два, а результат всего этого записывается в новый файл.
Теперь откроем в редакторе расшифрованный файл data_dec.pak и изучим его структуру.
Можно заметить что первые несколько байт архива, которые до этого были абракадаброй превратились в слово attack, но нас интересует конец файла, где, судя по всему, описана его структура.
Сравнив несколько блоков подряд, можно легко определить их структуру.
Первые 128 байтов из 140 - это текст, который содержит имя файла и виртуальный путь к нему.
Потом идет 4 блока по 4 байта, каждое из которых - число Int.
Первое число(выделено зеленым) - смещение относительно начала архива.
Второе число(выделено синим) - размер файла.
Третье число - всегда нули.
Четвертое число - тоже размер файла, оно всегда равно второму, по крайней мере для этого архива.
Первый файл называется
.\Animations\blackguy_with_bat\attack.ALF
Исходя из имени и пути к файлу, очевидно что это анимация атаки.
Смещение у него - 00 00 00 00 , т.е он начинается с самого начала архива, с первого байта.
Помните, когда мы открыли файл после расшифровки, в первых байтах было написано attack. Скорее всего формат этого файла с анимацией подразумевает, что в его начале хранится его имя.
Давайте перейдем по смещению 00 01 28 EC, т.е к концу первого файла архива, там мы видим слово fallback, что соответствует имени второго файла
.\Animations\blackguy_with_bat\fallback.ALF
Таким образом можно убедится, что все устроено так как мы и предположили.
А сейчас вернемся к тому второму альтернативному способу того как можно выяснить что делать с нечетными байтами.
В оглавлении архива нечетные байты встречаются только в смещениях, так как весь английский текст находится в первой половине таблицы ansi и всегда при умножении на два окажется в пределах 255.
Допустим мы не до конца декодировали архив,(пропусти все нечетные байты)
Для первых двух файлах в оглавлении текст у нас есть полностью, а вот смещения преобразованы только частично.
Вот что у нас бы получилось:
первый блок
имя:
.\Animations\blackguy_with_bat\attack.ALF
числа
00 00 00 00
00 01 28 D9 (D9 тут не декодирован так как это нечетное число 217)
00 01 28 D9
второй блок:
.\Animations\blackguy_with_bat\fallback.ALF
числа
00 01 28 D9
00 00 3F 2C (3F тут тоже не декодирован)
00 00 3F 2C
в любом случае 00 01 28 D9 выглядит вполне как 4 байтное число, и разумно было бы сходить посмотреть что там по этому смещению.
Попав на него мы видим что оказались в паре байт от слова fallback, складываем в уме 2+2, понимаем что вместо 00 01 28 D9 мы должны были бы попасть на 00 01 28 EC.
Выходит, что EC (236) ,которое при умножении на два дает 472, должно как-то превратится в D9 (217). Ну и тут уже очевидно что разница между ними равна 255. И мы приходим к точно такому же решению, которое было описано выше. Вуаля!
Ладно, а теперь забудьте все, что я выше писал.
Несмотря на то, что мы смогли разобраться в том, как создатели движка\игры обфусцировали данные архива, для меня очевидно, что какой бы халтурой для сруба бабла, не была их игра, они бы никогда не стали в код движка вставлять какие-то прибавления и вычитания “255”. Во-первых это слишком ресурсоемко(слишком много дополнительных операций) во-вторых слишком криво с точки зрения программирования.
Ясно, что должен быть какой-то более низкоуровневый и простой способ проделать с байтами тоже самое, что мы тут выше делали с помощью прибавления 255 и деления на 2.
Недолгий гуглеж на тему того, какие способы делить и умножать на 2 применительно к байтам и битам существуют, выдал результат в виде Битового Сдвига.
Еще вот тут по ссылке есть онлайн инструмент чтобы поиграться с битовым сдвигом для разных чисел или можно использовать даже калькулятор windows переключив его в “режим программиста”.
Если лень ходить по ссылкам, то объясню вкратце, как это работает:
Возьмем два примера которые мы разбирали выше.
DEC 100
HEX 64
bit 01100100
Если применить сдвиг бит влево для 01100100, то получится 11001000, что в свою очередь равно 200 и эквивалентно умножению на два.,
Если же взять число
DEC 236
HEX EC
bit 11101100
то при сдвиге влево мы получим 11011001 что в свою очередь равно 217(D9)
В этом месте мне бы хотелось показать какой-то максимально красивый и короткий java-код, который бы ультимативно доказал превосходство и красоту битового сдвига над убожеством деления на 2 и прибавления 255, но java к огромному сожалению делает сдвиг совсем не так, как нам надо, она или увеличивает число до двух байт при сдвиге влево, либо если ее принудительно ограничить байтом - сжирает сдвинутые единицы заменяя их нулями. Поэтому код для реализации сдвига так, как нам надо, будет выглядеть в разы более монструозно, в виду запредельного количества костылей, чем код с делением и прибавлением 255.
Но говнокодить в java нам и не нужно, мы будем использовать kaitai(он наговнокодит все за нас).
Осталось разгадать последнюю загадку.
Так как оглавление архива расположено в его конце, для того чтобы создать полноценный распаковщик, нам нужно каким-то образом программно научится понимать, либо где расположено начало этого оглавления, либо его размер.
И тут стоит обратить внимание на самый-самый конец архива.
Дело в том, что последний блок оглавления заканчивается за 4 байта до конца архива.
В декодированной версии эти байты - 4F FD FF FF, в изначальной 9E FB FF FF.
Тут мне по правде говоря пришлось серьезно поломать голову. Помог встроенный в редактор "инспектор данных" и то, что я при написании распаковщика додумался вставить туда счетчик блоков в оглавлении.
В архиве было 1122 файла(блоков в оглавлении) и при выделении последних 4 байт в оригинальном data.pak, до декодирования, у меня глаз зацепился за это число.
Оказалось что последние 4 байта в файле не нужно подвергать декодированию, и они хранят ОТРИЦАТЕЛЬНОЕ значение количества блоков в оглавлении.
Таким образом вырисовывался алгоритм распаковки:
Прочитать последние 4 байта, получить количество(1122) файлов\блоков
Декодировать путем битового сдвига вправо весь остальной файл
Умножить число блоков на размер одного блока(1122 умножить на 144(байт в блоке)
Отступить от конца файла 4+(1122*144) байт
1122 раза прочитать блоки оглавления, каждый раз извлекая соответствующий файл
Часть третья - Kaitai
Во-первых, почему и зачем. Мне в каментах к прошлой статье написали :
Ну я и решил его попробовать.
Итак что такое Kaitai и зачем он нам нужен в данном случае:
Kaitai - декларативный язык описания структуры бинарных данных
Он позволяет в текстовом .ksy файле используя специальный синтаксис, описать структуру таких файлов, как например игровой архив описанный выше, в том числе с обфускацией и сжатием.
Такой .ksy файл может быть использован сам по себе, например в составе библиотеки с описанием всевозможных форматов или же его можно скомпилировать в классы(исходный код) какого-нибудь языка и использовать в своем проекте для работы с этими бинарными файлами.
Давайте же посмотрим, что я с помощью Kaitai смог сделать.
Для начала вынесем обфускаций данных за скобки и опишем уже декодированный формат.
Так проще разобраться в новом для себя языке, а сходу не было понятно, можно ли в рамках Kaitai реализовать битовый сдвиг(оказалось можно и очень легко), а декодирование можно было бы потом прикрутить уже сверху средствами java.
Вот что получилось в первой итерации:
meta:
id: autothief_pak
file-extension: pak
application: CarJacker game
endian: le
instances:
toc_count:
pos: _io.size - 4
type: s4
toc:
pos: _io.size - 4 + toc_count * 144
type: toc_record
repeat: expr
repeat-expr: -toc_count
types:
toc_record:
seq:
- id: name
type: strz
encoding: ASCII
size: 128
- id: ofs_body
type: u4
- id: len_body
type: u4
- id: unk1
type: u4
- id: unk2
type: u4
instances:
file_content:
pos: ofs_body
size: len_body
Опишу по порядку:
Первый блок “meta” вроде понятен сам по себе - набор обязательный полей, дающий понять структура какого именно файла описана ниже.
Второй блок instances - это один из способов описание объектов. В данном случае я создал объект toc_count(количество записей в оглавлении), который находится на позиции “размер файла минус 4 байта” и типа s4 (Signed 4 bytes), а также объект toc, который находится по адресу “размер файла - 4 + кол-во записей умножить на 144”
При этом объект toc имеет повторяющийся тип toc_record, который описан ниже, с числом повторений равным переменной toc_count.
Дальше в блоке types идет описание единственного упомянутого мной "кастомного типа", который я упомянул - toc_record.
seq - второй(или первый) главный в kaitai способ описания объектов. В отличие от instances , которые могут иметь динамическое расположение и размер, данные внутри seq должны идти с начала, один за одним, и иметь фиксированный размер, наш toc_record как раз такой, если вы помните.
Итак, там у нас сначала name, строковое имя файла длиной 128 байт, z в конце strz в данном случае значит что 00 в конце строки можно обрезать.
Потом ofs_body - "смещение" файла в виде unsigned 4 bytes, такой же len_body - “размер файла” и два бесполезных числа.
Еще внутри каждой toc_record мы создаем в каком-то смысле “виртуальный” instance “file_content”, позиция и размер которого берется из значений самого toc_record.
Это и будет наш извлекаемый из архива файл.
Я сказал "виртуальный", потому что сам файл в архиве хранится отдельно от оглавления, но так как instances в Kaitai позволяют указывать любое расположение, то мы может как бы запихнуть ссылку на файл прямо в объект из оглавления.
Вроде бы на первый взгляд все получилось красиво. На языке Kaitai удалось минималистично и относительно понятно описать структуру игрового архива, игнорируя правда применяемое в нем кодирование путем битового сдвига.
На предварительно декодированном архиве этот .ksy файл работает как надо.
Но давайте попробуем реализовать еще и битовый сдвиг непосредственно силами Kaitai, оказывается он это может.
Для этого нужно по сути добавить одну вот такую строчку.
process: ror(1) ( сдвиг на 1 бит вправо)
Но тут возникает проблема, куда не всунь в .ksy-файле выше этот process: ror(1) он работать не будет.
Два года назад компилятор просто валился с кучей разных стэк-трейсов, в зависимости от места инжекта процессинга, я назаводил багов, сейчас проверил, стэк-трейсов нет, зато есть красивые ошибки текстом, которые говорят, что "процессинг так не может".
Насколько я понял, проблема в том что process не умеет работать внутри instances , его смущает неопределенность размера данных которые нужно подвергнуть обработки.
Поэтому пришлось несколько “обезобразить” красивый и компактный файл, добавив в него дополнительный уровень seq (в самом верху), чтобы заработал процессинг.
Вот что получилось во второй итерации, теперь с битовым сдвигом и как следствие полным декодированием:
meta:
id: autothief_pak
file-extension: pak
application: CarJacker game
endian: le
seq:
- id: body
size: _io.size-4
process: ror(1)
type: pak_body
- id: toc_count
type: s4
types:
pak_body:
instances:
toc:
pos: _io.size + _root.toc_count * 144
type: toc_record
repeat: expr
repeat-expr: -_root.toc_count
toc_record:
seq:
- id: name
type: strz
encoding: ASCII
size: 128
- id: ofs_body
type: u4
- id: len_body
type: u4
- id: unk1
type: u4
- id: unk2
type: u4
instances:
file_content:
pos: ofs_body
size: len_body
Теперь структура файла такая:
В самом верху фиксированные seq-объекты body и toc_count.
У объекта body размер “весь файл минус последние 4 байта” он имеет кастомный тип pak_body и к нему применен процессинг xor(1).
toc_count - это знаковый int(4 байта), который идет следом.
Ну а тип pak_body в свою очередь устроен точно так же как был устроен первый .ksy файл.
Получилось так что ради добавления процессинга, за который отвечает одна строка, файл пришлось усложнить и его размер вырос на 4 строки. Но хотя бы работает…
Теперь следующий этап. На основе этого .ksy файла мы с помощью компилятора kaitai сгенерируем java-классы. с помощью которых и будем работать с архивом(напишем красивый распаковщик)
Генерация происходит вот таким вот нехитрым образом из командной строки:
.\kaitai-struct-compiler.bat -t java autothief_pak.ksy
В результате у нас создается файл AutothiefPak.java размером 6кб, который мы добавляем в наш Java проект.
Чтобы этот класс заработал как надо, ему нужна библиотека kaitai-struct-runtime, которую в свою очередь можно добавить через maven или вручную.
В итоге для распаковки архива достаточно вот такого компактного кода:
public class Unpacker {
public static void main(String[] args){
AutothiefPak pack = AutothiefPak.fromFile("data.pak"); //загружаем и сразу парсим файл в обьект AutothiefPak
for (AutothiefPak.TocRecord file : pack.body().toc()){ //для каждой записи TocRecord внутри оглавления делаем следующее
Path filePath = Paths.get(file.name()); //извлекаем путь
Files.createDirectories(filePath.getParent()); // создаем директории под этот путь
Files.write(filePath, file.fileContent()); // записываем содержимое массива байтов fileContent в файл по этому пути
}
}
}
И как результат работы программы, у нас на диске появляется 121 папка со 1122 файлами представляющие собой распакованные и декодированные ресурсы игры.
Спасибо за внимание, надеюсь этот пост сподвигнет кого-нибудь к собственным успешным исследованиям.
Комментарии (5)
tolik_anabolik
22.09.2022 09:36Не знаю, похвалить ли вас за то, что своим умом дошли до циклического сдвига влево методом проб и ошибок. Или похаять за необоснованные претензии к java )))
Ваш псевдокод можно написать как:
byte[] data = Files.readAllBytes(Path.of("data.pak")); for (int i = 0; i < data.length; i++) { byte src = data[i]; data[i] = (byte) ((src << 1) | ((src >> 7) & 1)); } Files.write(Path.of("data_dec.pak"), data);
Hedzin Автор
22.09.2022 17:54Моя претензия к java была в том, что в идеальном мире , я для нужного мне битового сдвига хотел бы обойтись одним оператором << или >> на худой конец каким-то стандартным методом типа Integer.rotateLeft(int value, int shifts) ,который тоже делает битовый сдвиг.
но оба метода сдвигают биты не так, как мне хотелось бы.
вы же мне говорите "не смей ругать java" и предлагаете битовый сдвиг сделать с помощью ЧЕТЫРЕХ операторов. Хотя именно про это я и писал в статье, что нужны костыли усложняющие код.
WoWSab
Очень залипательное чтиво. Реверс на уровне HEX редактора - интересненько, интересненнько!
Hedzin Автор
ну, а с чего начинать, если не с этого :)
WoWSab
Ну как вариант с Ollydbg =) Но опять же - это вариант на любителя. Теоретически можно было бы открыть экзешник в отладчике, пробежаться по коду (привет ассемблер =^_^=), найти точку обращения к файлу, после проанализировать что там творится (там скорее всего было бы что-то из серии SBС EAX, 2 ), ну а дальше уже создать распаковщик по вашему сценарию. Не думаю что там была бы какая-то сложная защита по типу VM Protect, максимум upx. Но с таким подходом интересной статьи бы не получилось, а это явно минус, так что Ваш метод явно интереснее как по мне.