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