Разбор форматов: упакованные хешированные ресурсы

403f51764b7849249b0b64175fba1595.jpg

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

Рассмотрим это на примере довольно редкого движка bitsquid. Он простой и компактный, но, тем не менее, имеет все необходимые для современных игр возможности. В прошлом году bitsquid вместе с его разработчиком был куплен компанией Autodesk, и теперь они собираются скрестить его с Maya и сделать свой собственный игровой движок, который, как они обещают, будет чем-то невероятным.
Чтобы любой желающий мог сам посмотреть на процесс, воспользуемся демо-версией игры The showdown Effect, которая к тому же совсем небольшого размера (около 250МБ). Зайдя в папку content, в которой, очевидно, и находятся все ресурсы, обнаруживаем там пару десятков файлов вот с такими замечательными именами:

038bbacc4ce89296
0d42c15e8f2b473f
171e8b0d2241eb79
406c3644bd95237a
44bcc04093e5c506
680514e023d37cd5
71eec7a172194fe5
9229959b09a3b4be
9e13b2414b41b842
a6db0de7cf227dfe
a9956e471d528263
ac5c2f0670e5d674
b5af853949550001


Должно быть это файлы пакетов/архивов с ресурсами. Откроем один из них и посмотрим, что там внутри. А там на протяжении всего файла не видно ни таблиц, ни текстов, ни каких-то осмысленных чисел — сплошная мешанина байтов:

b6aa5a4e95c14aa49f266bf7a591eb1a.png

Это обычно означает, что все данные зашифрованы и/или запакованы. В данном случае почти в самом начале комбинация байт 78 9C (выделены зеленым) однозначно говорит, что данные сжаты zlib. Попробуем для начала распаковать файл «вручную». Для этого применим offzip — утилиту, которая просто пытается распаковать любые последовательности байт внутри файла, как если бы они были упакованы zip или zlib, сколько бы их ни было в файле и в какой последовательности.

Выполняем следующую команду: offzip -a 9e13b2414b41b842 unp 0

Опция -a здесь означает, что надо попытаться найти в файле все сегменты, сжатые zlib, а не рассматривать файл как единственный сжатый блок. «unp» — папка для распаковки (её нужно предварительно создать).»0» — начальное смещение, то есть искать с самого начала файла.

Получаем следующее:

+------------+-----+----------------------------+----------------------+
| hex_offset | ... | zip -> unzip size / offset | spaces before | info |
+------------+-----+----------------------------+----------------------+
  0x00000010  24803 -> 65536 / 0x000060f3 _ 16 8:7:28:0:1:441d52d8
  0x000060f7  21186 -> 65536 / 0x0000b3b9 _ 4 8:7:28:0:1:74fe0bf1
  0x0000b3bd  16694 -> 65536 / 0x0000f4f3 _ 4 8:7:28:0:1:4bdbbd7f
  0x0000f4f7  17028 -> 65536 / 0x0001377b _ 4 8:7:28:0:1:4cae9920
  0x0001377f  16200 -> 65536 / 0x000176c7 _ 4 8:7:28:0:1:aa6b718e
  0x000176cb  14445 -> 65536 / 0x0001af38 _ 4 8:7:28:0:1:e190c104
[поскипано...]
  0x04ec0fb4  17108 -> 65536 / 0x04ec5288 _ 4 8:7:28:0:1:952f8201
  0x04ec528c  17139 -> 65536 / 0x04ec957f _ 4 8:7:28:0:1:373c403f
  0x04ec9583  22442 -> 65536 / 0x04eced2d _ 4 8:7:28:0:1:8e95fe5c
  0x04eced31  4215 -> 65536 / 0x04ecfda8 _ 4 8:7:28:0:1:93e0ac5a

- 1483 valid compressed streams found
- 0x04d7e61c -> 0x05cb0000 bytes covering the 98% of the file


Как видим, 98% содержимого файла распаковалось в кучу сегментов по 64кБ каждый. Проанализировав их содержимое, можно заметить, что они представляли из себя единое целое — один большой файл, который был просто порезан на кусочки по 64кБ и затем сжаты по отдельности zlib-ом. В принципе могло быть и наоборот — каждый исходный ресурс сжат отдельно и потом все они слеплены в один большой файл. Но в нашем случае файл один, поэтому распаковать его можно такой командой:

offzip -a -1 9e13b2414b41b842 unp 0

Опция -1 означает, что все обнаруженные распакованные сегменты нужно соединить. В итоге мы получаем распакованный файл, который опять же надо изучить. Помотав его туда-сюда, можно обнаружить, что внутри имеются и lua-скрипты, и звуки, и текстуры, теперь уже несжатые, но слепленные все вместе.

421b233b6ec8474d8717a066d4f432ef.png

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

41589f045a07422db5da692b504940f5.png

Далее, оказывается, что последняя строка в таблице почему-то такая же, как первая. К тому же, если посмотреть несколько файлов, похоже, что первое число в них — это как раз число строк таблицы (правда, минус один). Сопоставив все эти данные, можно заключить, что это таблица, где записаны имена ресурсов в виде хеша, отдельно имя ресурса и его тип. А последняя строка — это уже не таблица, а информация о первом ресурсе, где в начале видимо как раз идёт его хеш, а потом должны быть размер, другие параметры, и сам файл. Чтобы убедиться в этом, поищем остальные числа в файле, и, конечно же, они находятся, причём именно в той последовательности, как идут в таблице.

Хорошо, теперь осталось разобрать формат записей и попытаться угадать захешированные названия. В принципе может быть так, что игра обращается к ресурсам уже по хешу, и исходных названий в ней не осталось, в таком случае названий мы не найдём. Но к счастью, чаще всего их можно найти, угадать, или вычислить по коду или скриптам. Кстати, насчет скриптов: мы уже видели, что здесь используется lua, значит, скорее всего, расширение для таких файлов будет «lua». Вид используемого хеша можно определить по наличию в коде известных констант. Например, в FNV используется число 0×811C9DC5. Если же применяется собственный алгоритм, он обычно простой, типа сложения со сдвигом, но найти его в коде будет уже не так просто.

Я уже было собрался искать 0×811C9DC5, но решил для начала погуглить, и оказалось, что разработчик bitsquid у себя в блоге как-то рассказывал о преимуществах хеша murmur64. Как у любого хеша, у murmur есть разные версии, но 64-битный — это как раз 8 байтов, как в нашей таблице. Исходный код нашелся здесь. Скомпилируем его и попробуем посчитать хеш строки «lua». Правда, мы не знаем, чему равен seed, поэтому пока попробуем взять ноль.

Получаем murmur64 от «lua» = A14E8DFA2CD117E2

Это число как раз часто встречается в нашем файле! Поздравляем, теперь мы знаем, как игра считает хеш. Если бы seed не был нулём, пришлось бы опять-таки смотреть или отлаживать код, чтобы это узнать. Это может быть константа, или длина текстовой строки, а вообще это может быть что угодно. Например, первый символ, склеенный с длиной строки.

Ну хорошо, мы знаем одно из расширений, неужели теперь придётся по одному угадывать все остальные? Возможно, бывает и так. Но давайте попробуем поискать где-то их список, так сказать, в открытом виде. Он может быть в одном из lua-скриптов, или прямо в исполняемом файле, как в данном случае:

e2887dd8dc2141dca5b8602e5229251e.png

В середине я выделил строки, которые точно являются типами ресурсов. Но где этот список начинается, и где кончается? Это можно определить экспериментально.

Попробуем для примера murmur64 от «unit» = E0A48D0BE9A7453F

И действительно, такой код есть. Вроде бы очевидное название, но угадать его с первого раза было бы не так просто. А звуковые банки так вообще называются «timpani_bank», вот уж ни за что не угадал бы.

Итак, теперь мы знаем все типы ресурсов (расширения файлов), но как узнать их имена? Они могут быть в ресурсах или в коде. Посмотрим например .ini файл, который лежит рядом с архивами.

boot_package = "resource_packages/boot"
boot_script = "scripts/boot/boot"
pdxigs = {
        game_name = "Showdown"
        game_version = "1.0.0"
        server_url = "http://xxxxxxxx.xxxxxxxxxxxxx.com/xxxx"
}
steam = {
        notification_position = "bottom-left"
}
timpani = "content/sounds/shoot"

Вот и первая зацепка — загрузочный пакет называется «resourse_packages/boot». Посчитаем хеш этой строки — 9E13B2414B41B842, он есть в нашем списке. В нём наряду с другими файлами содержится загрузочный скрипт

«scripts/boot/boot» = BBF3D6DD1B2AC672.

В нём внутри ссылки на другие скрипты, например, «scripts/boot/boot_common». В этом коммоне, в свою очередь, есть множество строк, в том числе

«resource_packages/base_game_resources» = 0D42C15E8F2B473F

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

Итак, предположим, мы нашли все названия, и теперь ресурсы у нас имеют осмысленные имена и расширения. Вернёмся к формату файла.

39fbeb332b8f4a23a3a72d3e9d870491.png

После таблицы — списка хешей (выделена желтым) начинаются отдельные записи для всех ресурсов. Как мы уже выяснили, первая строчка (выделена зеленым) — это имя и тип ресурса. Здесь 82645835E6B73232 = «config», правую часть (имя) мы пока не знаем. Попробуем угадать, что же идёт дальше. По всей видимости, тут у нас несколько 32-битных чисел. Сначала единичка, потом два ноля, дальше еще одно число (выделено розовым), похожее на размер, и еще один ноль. Неизвестно, что это, но у всех файлов эти числа именно такие. Потом начинается собственно содержимое ресурса. Проверим его длину. Прибавим размер 045С к смещению, где начинается запись, 0518, получим 0974.

da1b675626334568b49a9b0d4a2ff057.png

Да, действительно, здесь уже следующий хеш. 9EFE0A916AAE7880 = это «font», далее всё то же самое, что в первой записи и длина фонта — 1838. Далее идёт сам фонт, он начинается большой серией плавающих чисел, их тоже обычно легко видно невооружённым глазом. Например 42000000 — это 32, 41С80000 — это 25, и конечно 3F800000 — самое часто встречающееся в игровых файлах плавающее число — это 1.

Вроде бы формат записей в архиве мы разобрали. Похоже, единственное, что у нас есть для каждого ресурса, это его размер. Странно, что нет смещения, ну, бывает и так. Остальные числа — нули, возможно, они что-то значат, но нам это неизвестно. Проверим на всякий случай последнюю запись, прибавив длину последнего ресурса к адресу его начала. Получаем 4ECFDA0 — это как раз общая длина файла. Похоже, в конце больше ничего нет, значит можно приступать к написанию программы распаковки. Она будет читать файл пакета и разделять его на ресурсы. Если хеш имеется в нашем списке — сохранять файлы с правильным именем, если нет — в качестве имени берётся сам хеш.

Запускаем программу — и она успешно распаковывает кучу файлов из нашего пакета. Проверим их содержимое. Текстуры действительно получились корректными DDS-файлами, звуки проигрываются как обычные OGG, остальные файлы (например, модели юнитов) хотя и имеют какой-то особый формат, тоже выглядят правдоподобно.

Воодушевленные успехом, начинаем распаковку всех остальных файлов. И тут нас ожидает Unhandled Exception. Практически все файлы распаковались, кроме нескольких. Так обычно и бывает. Среди тысяч файлов обязательно найдётся один-два, упакованных как-нибудь нестандартно, или с дополнительными параметрами. Посмотрим, что не так с этими файлами. Оказывается, единичка после хеша была неспроста. В этом файле здесь не единичка, а семёрка. Мало того, при распаковке других игр, сделанных на том же движке, оказалось, что и нули там тоже не всегда нули. Но и это ещё не всё. Есть один файл, структура которого, кажется, вообще нарушена. При попытке найти в нём очередной ресурс оказывается, что оставшегося куска файла не хватает для ресурса такой длины.

Посмотрим запись об этом конкретном ресурсе. Вроде бы всё правильно: хеш, после него единичка, два нуля, потом размер. Как же так? Может быть, файл неправильно распаковался? Ну-ка, попробуем ещё раз. Возможно, в мелькании сотен строк вывода offzip мы что-то не заметили?

  0x0286c4e2  65196 -> 65536 / 0x0287c38e _ 4 8:7:28:0:1:340433a1
  0x0287c392  65242 -> 65536 / 0x0288c26c _ 4 8:7:28:0:1:27dce3e7
  0x0288c270  65415 -> 65536 / 0x0289c1f7 _ 4 8:7:28:0:1:b9bd6cd0
. 0x028cac59....................
- zlib Z_DATA_ERROR, the data in the file is not in zip format
  or uses a different windowBits value (-z). Try to use -z -15
  0x028cc207  65533 -> 65536 / 0x028dc204 _ 196624 8:7:28:0:1:54aa0921
  0x028dc208  65513 -> 65536 / 0x028ec1f1 _ 4 8:7:28:0:1:c4b3abd4
  0x028fc1f9  65533 -> 65536 / 0x0290c1f6 _ 65544 8:7:28:0:1:890356ae
  0x0290c1fa  65534 -> 65536 / 0x0291c1f8 _ 4 8:7:28:0:1:934a442c
  0x0292c200  65496 -> 65536 / 0x0293c1d8 _ 65544 8:7:28:0:1:c21356fb
  0x0293c1dc  65521 -> 65536 / 0x0294c1cd _ 4 8:7:28:0:1:5bf3ea59
  0x0294c1d1  65514 -> 65536 / 0x0295c1bb _ 4 8:7:28:0:1:d7c697a0


Да, действительно, какая-то проблема. Именно в этом файле присутствуют сегменты, которые не распаковались. Кстати, что это за ресурс такой? Посмотрим содержимое файла по смещению 0×028cac59, или чуть раньше.

44021a15eb6f4c3e926408326225c857.png

А это как раз «timpani_bank». Ну конечно! Ведь ogg-поток может содержать настолько хаотичный поток битов, что zlib просто не может его сжать, в итоге из 92-МБ файла несколько 64-кбайтных сегментов после сжатия получились даже больше, чем 64к. Видимо разработчики резонно решили, что в таком случае нет смысла их сжимать, и поместили в архив прямо как было. Поэтому offzip не смог найти там заветные байты 78 9C, и в итоге просто пропустил их при распаковке.

Никакого флага/признака для отличия сжатых и несжатых сегментов в структуре файла нет, значит игра поступает просто: если сегмент имеет размер меньше б4к — значит он упакован, если же ровно 64к — то нет. Однако и тут не всё так просто. Были случаи (в другой игре, на другом движке), когда после упаковки сегмента его длина оставалась точно равной 64к. И вот тут уже никак не определить, сжатый он или нет. Хотя вероятность такого совпадения очень мала, это тоже придётся учитывать.

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

© Habrahabr.ru