Мою первую статью я желаю посвятить истории о том, как я решил заняться исследованием часто встречающихся в модулях PlayStation Portable непонятных байтовых строк. Никакой документации в Homebrew коммьюнити найти не удалось, так что я взялся за дело сам.


Я начал реверсить игры под PSP с целью моддинга где-то два года назад, до этого как-то всё не мог собраться, хотя и наблюдал за Youtube-каналами других моддеров. В своём локальном коммьюнити по моддингу трилогии Patapon я неожиданно стал одним из самых продвинутых специалистов и начал ради интереса исследовать в Гидре абсолютно все игры, какие были на моей PSP-Go. Пока я это делал, я начал замечать, что в разделе данных иногда встречаются очень похожие друг на друга почти-что-ASCII строчки.

Больше часа искал этот скрин в Дискорде, когда писал статью...
Вся эта последовательность ненулевых байт меня очень тогда заинтересовала
Вся эта последовательность ненулевых байт меня очень тогда заинтересовала

На них никто в программах не ссылался, так что способа выяснить, что это за звери, не было.

В один прекрасный день я заметил, что абсолютно такие же строчки присутствуют внутри ~SCE хедеров некоторых PRX модулей.

PRX в контексте Playstation Portable

Сейчас с моей стороны продолжить повествование и не обрисовать, что такое PRX, неприемлемо.

На PSP исполняемые файлы имеют формат PRX (Playstation Relocatable Executable).

На самом деле, это немного модифицированный ELF:

  1. Переосмыслено поле p_paddr в структуре Elf32_Phdr

  2. Добавлены новые виды релокации

  3. Файл зашифрован криптографической системой KIRK

  4. Приписан хедер, начинающийся с ~PSP

Однако существует ненулевое число модулей (как бы библиотеки, как обычный .dll/.so), у которых в начале стоит ещё один хедер:

Файлик шрифтовой библиотеки в ресурсах игры
Файлик шрифтовой библиотеки в ресурсах игры

PPSSPP и загрузчик самой PSP в таком случае поступают максимально просто - считывают размер этого хедера (байты [0x4; 0x7]), а затем скипают его, как будто его и не было. Как вы видите, внутри этого хедера лежит строчка, аналогичная той, которую я нашёл в данных игры (выделена). Если уж сама PSP не обращается к этому месту, становится понятно, что нам предстоит задача, аналогичная задаче понять язык, на котором уже никто не умеет ни читать, ни говорить.


Мой реверс-инжениринг сильно упростился, когда я нашёл достаточно много игр, которые ушли в релиз с дебажной информацией (я не о логах, которые в небольшом количестве присутствуют почти во всех играх, а о настоящей "-g" отладочной информации с номерами строк, сигнатурами функций, в общем, DWARF в лучшей своей форме).

В одной из игр я увидел, что рядом со строчкой стоял символ __psp_libinfo__, так что я стал исходить из того, что смысл этих строк - что-то вроде версии либы, с которой была статически слинкована программа, а называть их стал “libinfo” (либи́нфо, во множественном числе “libinfos”, либи́нфы, под ударением звук "ы"). Все PRX файлы с хедером ~SCE, которые были в моём распоряжении, оказались очень обрезанными - не то что дебажной информации или имён секций нет... Там вообще секций нет. Впрочем, внутри каждого нашлась ровно одна либинфа, совпадавшая с либинфой в хедере.

У меня сразу созрел план: собрать базу таких либинфочек, а потом автоматом Ахо-Корасик пройтись по файлам игр, чтобы понять, какие там присутствуют либы.

Сразу узнаем, какие функции из SDK есть смысл искать в бинарнике, а какие - нет! А там уж и до содержательного кода дойдём...
Сразу узнаем, какие функции из SDK есть смысл искать в бинарнике, а какие - нет! А там уж и до содержательного кода дойдём...

Оказалось на практике, что у файлов с одинаковым именем могут быть разные либинфы, так что я начал приписывать к дублям суффикс _{номер}.

База - обычный JSON-файл, в котором имени сопоставляется base64 от байтов либинфы
База - обычный JSON-файл, в котором имени сопоставляется base64 от байтов либинфы

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

Я решил, что пора взламывать эти строки. Первая идея - XOR с каким-то ключом. Перебрал 256 ключей - всё мимо. Пробовать большие размеры ключей я не стал, т.к. там либо непонятно, как ксорить, либо уже слишком сложная задача выходит. Кроме того, очевидно, что эти строки никакие не хеши: слишком похожи…

Я упомянул чуть ранее автомат Ахо-Корасик. Если вы не знали, он базируется на структуре данных "бор".

Бор, он же Trie

(Кстати, от слова reTRIEval)

Бор, содержащий слова “he”, “she”, “his” и “hers”
Бор, содержащий слова “he”, “she”, “his” и “hers”

Эта структура позволяет относительно эффективно (и наглядно) хранить множество строк. Можно добавлять новые элементы, удалять их, а также проверять, есть ли строка во множестве. Бор - это корневое ориентированное дерево, где рёбра олицетворяют символы, а вершины - слова. Первое добавляемое в бор слово просто разбивается на цепочку символов и подвешивается к корню, при этом последняя вершина помечается, как "терминальная". Это нужно, чтобы структура знала, какие слова на самом деле содержатся в ней. При добавлении нового слова бор делает то же самое, но вместо того, чтобы подвешивать всегда к корню, он создаёт новую ветку в том месте, где у него уже не хватает символов в дереве (т.е. новое слово как бы отпочковывается в середине ветви). Таким образом достигается экономия - если у двух слов есть общий префикс, то он будет сохранён лишь единожды (см. фото выше). Чтобы добавить слово, чья цепочка уже имеется в боре, мы просто делаем последнюю вершину в ней терминальной.

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

Неочевидным остаётся вопрос реализации этого дерева, т.к. каждая вершина должна как-то знать всех своих детей, причём проверка на включение должна быть быстрой, так что вектор из std::unique_ptr не очень подойдёт. Если алфавит (множество букв, которое может встретиться в строчках) имеет размер N, можно в вершине хранить массив из N указателей и char превращать в индекс через вычитание первого символа алфавита… Но не всегда это удобно, так что я использовал словари. Кроме того, я добавил всем вершинам поле "тег", чтобы хранить какую-нибудь дополнительную информацию в нём.

Получился вот такой код:

class TrieNode:
    def __init__(self):
        self.value = 0x0
        self.terminal = False
        self.children: Dict[int, TrieNode] = collections.defaultdict(TrieNode)
        self.tag: Any = None

    def __repr__(self):
        return chr(self.value)

    def __str__(self):
        return chr(self.value)


# That's the generator behind the TrieIterator class
def get_trie_words(root: TrieNode):
    if root.terminal:
        # We're a word in the Trie, let's return nothing
        yield b""

    for letter, child in root.children.items():
        suffixes = get_trie_words(child)
        yield from (letter.to_bytes(1, "little") + word for word in suffixes)


def count_trie_words(root: TrieNode):
    count = 1 if root.terminal else 0

    for child in root.children.values():
        count += count_trie_words(child)

    return count


# Constructed by the Trie.__iter__ method
class TrieIterator:
    def __init__(self, node: TrieNode):
        self.gen = get_trie_words(node)

    def __iter__(self):
        return self

    def __next__(self):
        value = next(self.gen)
        if value is None:
            raise StopIteration
        return value


class Trie:
    def __init__(self):
        self.root = TrieNode()

    def add(self, word: bytes, tag: Optional[Any] = None):
        current = self.root
        for letter in word:
            current = current.children[letter]
            current.value = letter
        if not current.terminal:
            current.terminal = True
            if tag is not None:
                current.tag = tag

    def __contains__(self, item):
        current = self.root
        for letter in item:
            if letter not in current.children:
                return False
            current = current.children[letter]
        return current.terminal

    def __getitem__(self, item):
        current = self.root
        for letter in item:
            if letter not in current.children:
                raise KeyError(f"{item} not found in Trie!")
            current = current.children[letter]
        return current

    def __iter__(self):
        return TrieIterator(self.root)

    def __len__(self):
        return count_trie_words(self.root)

Эффективен ли этот код? По-моему, get_trie_words - жуткий костыль, но у меня нет абсолютно никаких причин переосмысливать всю структуру. Тем более, что у меня нет ограничений по времени на работу...

Я сразу решил, что бор нужно визуализировать посредством графвиза. Как устанавливать его, рассказывать не буду, есть достаточно внятные инструкции в инете. К счастью, я уже с ним работал через Python (pygraphviz позволяет эмиттить *.gv-файлы), так что в этот раз было не шибко сложно. Мне было нужно обойти бор и зарегистрировать вершины (они здесь именуются не "vertex", а "node"), а затем проделать то же самое с рёбрами.

Обычно в боре пишут символы на рёбрах, а в вершинах пишут слова целиком, но я догадывался, что вершины будут огромные и картинка получится не очень наглядной, так что в вершинах я тоже писал символы. (Ну, кроме терминальных, конечно… Там всё-таки надо написать слова). Чтобы было совсем удобно, я всем терминальным вершинам в качестве тега дал имя модуля и разместил под строчкой. Кстати, тут уже base64 не поможет, надо запихивать наш недо-ASCII в граф. Я сделал свой рендерер, т.к. Питон считает, что \x7F - printable. Может, DEL и на самом деле printable, но мне квадратики в консоли и пустое место на картинке не нужны…

Исследование

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

  1. Длины либинф

  2. Суммарный алфавит всех либинф

  3. Символы, встречающиеся на i-ой позиции в либинфах

  4. Сжатая форма алфавита - в отсортированном алфавите схлопываем рэнжи последовательно идущих символов (скажем, лист ['A','B','C','D','M','X'] превращается в строку "[A; D], M, X")

А что насчёт частот символов?

Частотным анализом я баловаться не стал, т.к. не было никакой уверенности, что за этим кроется речь на каком-то языке. Тем более, что непонятно, с чем такое можно сверять… Ну вдруг это зашифрованный Shift-JIS с иероглифами… А даже если английский, оно всё равно оно маловероятно будет подчиняться известному распределению, всё-таки выборка очень специфичная.

Оказалось, что все строчки имеют одинаковую длину 0x18 байт, причём у них общий префикс длиной 8 байт: \yr=`c`0. Впрочем, последнее наблюдение можно было сделать и с помощью графа.

Вот такой вот ствол у моего дерева
Я по традиции экспортирую в SVG, т.к. обычно мои графы ОЧЕНЬ большие и на PNG не влезают в приемлемом качестве…
Я по традиции экспортирую в SVG, т.к. обычно мои графы ОЧЕНЬ большие и на PNG не влезают в приемлемом качестве…

Дальнейшие открытия без визуальной составляющей я бы уже не сделал. Вглядываясь в SVG, я заметил, что либинфы, соответствующие модулям с одинаковыми названиями, как бы сгруппированы в поддеревья. Более того, глубина таких поддеревьев - 4 вершины, не считая корневую.

Семейство таких похожих, но таких разных libmd5.prx
Семейство таких похожих, но таких разных libmd5.prx

Теперь я бы хотел закрепить терминологию: префикс либинфы - первые 8 байт, тело либинфы - следующие 12 байт, суффикс либинфы - последние 4 байта.

У меня возникла гипотеза, что тело отвечает за идентификацию модуля, а суффикс - за версию. С учётом этого я сгенерировал новый SVG, где в качестве слов уже использовал обрезанные либинфы. Отрезал я префикс и суффиксы, а в качестве имени модуля использовал первый попавшийся из поддерева. Стало лучше, я бы сказал, минималистичнее.

Я был готов сдаться и начать паковать мои скрипты, чтобы отдать на растерзание PSP Homebrew коммьюнити, как вдруг меня осенило.


Пол второго ночи… Я очумелыми глазами пялился в группу библиотек libsfmt{цифры}, как вдруг я заметил, что на рёбрах в поддереве были написаны капсом буквы латинского алфавита. Немного сопоставлений... и я понял, что это всё значит!

Двойку я воспринял, как некий терминальный символ, которым забивают тело, если ещё остались байты
Двойку я воспринял, как некий терминальный символ, которым забивают тело, если ещё остались байты

Тогда-то я и понял, что это, скорее всего, шифр Цезаря! В данном случае, правда, алфавит - множество из 256 значений одного байта. Первые 128 - ASCII, остальное - вообще говоря, ничего, но на практике там у нас живёт региональный extended ASCII… но не будем о грустном.

def _transform(word: bytes, shift: int):
        ans = bytearray(len(word))
        for index, letter in enumerate(word):
            ans[index] = letter - shift
        return ans.decode("ascii")

Я написал дешифровщик для нашего шифра и прогнал его на всех либинфах, взяв сдвиг равным -0x12. Все тела дешифровались и оказались просто сокращёнными версиями имён модулей (двойки оказались пробелами), а вот префикс и суффикс не починились. Тогда я предположил, что они зашифрованы другим сдвигом.

Начал я с префикса… Перебрал все сдвиги, ничего не нашёл.

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

Единственным сдвигом, который выглядел правдоподобно, оказался -0x14
Единственным сдвигом, который выглядел правдоподобно, оказался -0x14

Я предположил, что это версии SDK. Пошёл читать сурсы PPSSPP и подтвердил теорию! "500b", скорее всего, означает бета-версию! Что ж, пожалуй, теперь всё встаёт на свои места - у нас ревизий отдельных модулей может быть мало, а вот самих SDK было много.

Затем я ещё раз прогнал тесты над префиксом и понял, что плохо искал…

Всё было перед глазами, но я проглядел это.
А ларчик просто открывался!
А ларчик просто открывался!

Я поделился открытием с создателем PPSSPP (Henrik Rydgård), после чего он спросил, не смог бы я PRнуть в эмулятор логирование загрузки таких ~SCE модулей. Я смог. Здесь же можно посмотреть на пример полностью дешифрованных имён библиотек (на скрине).

Заключение

Я чрезвычайно рад, что смог наконец-то проломить эту тайну, которая меня донимала несколько месяцев!

  1. Во-первых, я себя проявил в реверсе самой PSP, хотя казалось бы, что уже ничего не осталось там неизвестного, что мне подвластно.

  2. Во-вторых, теперь можно официально начать охоту на статические библиотеки в исполняемых файлах. Пока я исследовал имеющиеся под рукой игры, я наткнулся на Locoroco 1. Там я нашёл ранее неизвестные якобы стандартные функции (начинаются с "sce", например scesupMsiolRename). Вообще, сейчас есть уже как минимум четыре либы, про которые нет никакой информации в интернете: supac2ms-SJ9, libwr2pt-SJ2, libmsiol-SJ5, spac2msR-PD0. Будем исследовать! Нашлась и либа, где последним символом стоит нуль-байт (и после дешифровки мы улетаем в область 0xec), так что я в pull request PPSSPP добавил проверку через isprint перед выводом в консоль.

  3. В-третьих, моё открытие дало доказательство, что Lib-PSP iplloader не является официальным названием бутрома PSP (и поэтому на странице вики убрали Lib-PSP из альтернативного названия).

Если будет интересно, я планирую сделать что-то вроде цикла статей про Гидру и с чем её есть, к тому же я сам себе задолжал статью про ImHex.

Комментарии (14)


  1. Zexyl
    04.07.2024 01:22
    +2

    Интересная история, пишите ещё!


  1. 3263927
    04.07.2024 01:22

    ого! супер! недавно паял какуюто консоль, там такое всё маленькое! не видно даже что паяешь, припаял - потом в микроскоп посмотрел...

    LocoRoco очень люблю, играл на PSP у друга! интересно, можно её запустить на компе или нет?

    про гидру очень интересно! будем ждать статью!


    1. Nemoumbra Автор
      04.07.2024 01:22

      Всё, что связано с пайкой, мне незнакомо... Я софт исследую, а что касается харда, то тут уж "Я это не понимаю, мне это не интересно... Вот мне лично это не интересно, за других сказать не могу".

      PPSSPP поддерживает все популярные игры (и Locoroco в частности). В менее популярных могут быть, например, редкие визуальные баги, о которых hrydgard просто ещё не знает. Есть подраздел на сайте PPSSPP, где собраны отчёты пользователей о качестве эмуляции игр: https://report.ppsspp.org/games

      Если видите баг, смело делайте Issue на Гитхабе. Спасибо за фидбек!


      1. nokeya
        04.07.2024 01:22

        А в интернетах еще осталась дебажная версия локороко 2? Когда-то давным-давно её слили на игромире перед релизом. Может в ней есть что-то интересное тоже


        1. Nemoumbra Автор
          04.07.2024 01:22

          Первый раз в жизни слышу об Игромире.
          Вы уверены, что была прям дебажная версия? Есть некоторый список игр с дебажной инфой, но там нет LocoRoco 2... Да это был бы ор выше гор! На сайте PPSSPP я вижу упомянания демок, может быть, о них речь? Если правда что-то было, я не против на это поглядеть.

          Кстати, во всех играх LocoRoco есть скрытые логи, которые можно включить только при запуске из-под специального дебажного оборудования, если щёлкнуть переключателями GPI. Но с недавних пор PPSSPP их поддерживает, так что можно и у себя на компе)


          1. nokeya
            04.07.2024 01:22

            Ну это скорее альфа/бета не для широкой публики

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


          1. nokeya
            04.07.2024 01:22

            А про игромир - ее сдампили с демо-консоли на выставке:)


  1. Hinedes
    04.07.2024 01:22

    Вау! Бывают же гениальные люди! Очень интересно было почитать!


  1. amaranth
    04.07.2024 01:22
    +3

    Давайте статьи про гидру. Спасибо


  1. DrMefistO
    04.07.2024 01:22
    +1

    Круто, спасибо! В своё время я так озадачился либами для PS1, PsyQ. В итоге запилил сигнатуры, которые с высокой точностью определяют все использованные PSYQ библиотеки и функи из них. Заодно склад из самих SDK насобирал.

    Всё это добро можно найти в ghidra_psx_ldr.


    1. Nemoumbra Автор
      04.07.2024 01:22

      А что Вы называете сигнатурами? Гидра умеет выполнять пасс "Function ID" по заданной базе (сомнительно, но окэй), а ещё недавно научилась обращаться к базе BSim, чтобы мэтчить функции. Наверное, у Вас первый кейс.


      1. DrMefistO
        04.07.2024 01:22
        +1

        Нет. FunctionID мне не подошёл, так как не цеплял некоторые очень маленькие функции. Пришлось и свой матчер с применялкой пилить)


  1. SneakyF0xy
    04.07.2024 01:22

    Крутая статья! Как пришла идея с деревом? Вам надо было эти строчки через какой-нибудь dcode.fr прогнать и радоваться)


    1. Nemoumbra Автор
      04.07.2024 01:22

      Прикольный сайт, спасибо! Это, конечно, не полное решение проблемы, но с префиксом он справился.
      Что до дерева... Ну, мне показалось, что у строк были похожие символы в начале (и это позже подтвердилось), да и в конце были похожие паттерны, так что я захотел визуализировать отличия между строками => это привело к идее использовать бор.