March 23

Как создать свой приватный сервер на Brawl Stars?

Многие дети задумываются в настоящее время:

А как я могу создать свой приватный сервер по Brawl Stars?

... и бегут качать слитые сурсы серверов. Да. Но это плохо.

Так а почему это плохо?

Как минимум, скачивая уже готовый сервер и просто модифицируя готовый клиент вы ничему не научитесь.

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

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

Вам понадобится...

  1. Компьютер (99% школьников дальше могут не читать)
  2. Знания любого языка программирования, который может работать с TCP-соединениями и поддерживает работу с сокетами.
  3. Хотя бы базовые знания реверс-инженеринга

Из инструментов:

  1. Дизассемблер (IDA Pro, Ghidra, Binary Ninja, т.п.)
  2. Frida (будем писать скрипты)
  3. Редактор кода (можно даже блокнот использовать)

Предисловие

В данном гайде будет использоваться язык программирования TypeScript (по сути JavaScript, но с классными рофлами) и дизассеблер IDA Pro.

Мы будем называть пакетами - ответ, полученный/отправленный серверу/клиенту, а сообщения - экземпляры классов для работы с пакетом.

Для удобства читателя, мы будем использовать дизассемблированный код от ARM64 версии 36.218 (Лето с Динозаврами!), так-как там присутствуют дебаг-символы (названия функций).

Для начала, что нам надо сделать?

Мы напишем простенькую логику сервера, дабы наш будущий сервер смог хотя бы получать и слать пакеты.

Для начала, нам нужно написать raw сервер (по сути просто пустышка). И для этого, мы будем использовать модуль net

Сервер-пустышка.
Теперь сервер прослушивает порт 9339. К нам подключился клиент.

Отлично! Наш сервер может получать подключения от клиента. Но вот пакеты получать пока не умеет. Давайте будем писать логику так, как (в теории) пишут разработчики (нет, говнокодить не будем. возможно).

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

По сути, самый главным классом, отвечающим за работу клиента с сервером является ServerConnection. Допустим, мы нашли его конструктор.

Перейдя в псевдокод, мы можем наблюдать месево из строчек кода. Но самое важное, я буду выделять ниже.

__int64 __fastcall ServerConnection::ServerConnection(__int64 a1, __int64 a2)
{
  ...
  ServerConnection::init(a1);
  ...
}

А именно, ServerConnection::init(a1). Перейдя в него, мы можем выделить следующие важные для нас строчки:

void __fastcall ServerConnection::init(__int64 a1)
{
  Messaging *v2; // x20
  LogicLaserMessageFactory *v3; // x21
  ...
  
  ...
  Messaging::Messaging(v2, 80);
  *(_QWORD *)(a1 + 8) = v2;
  ...
  LogicLaserMessageFactory::LogicLaserMessageFactory(v3, 0);
  Messaging::setFactory(v2, v3);
  ...
  MessageManager::constructInstance(*(MessageManager **)(a1 + 8), v10);
  ...
}

А именно - конструкторы классов Messaging, LogicLaserMessageFactory. Ну и ещё MessageManager

К ним мы вернёмся позже, а пока нам надо понять роль класса ServerConnection

Поискав функции в поиске, мы можем найти следующие функции:

  1. ServerConnection::connect/ServerConnection::connectTo
  2. ServerConnection::disconnect
  3. ServerConnection::saveAccount
  4. ServerConnection::resetAccount
  5. ServerConnection::is{something}Account(Saved/Loaded)

Ага! Значит роль ServerConnection - подключаться/отключаться от сервера и хранить аккаунты! Ведь так?

Ну, в целом да. Но если взглянуть в, условный, ServerConnection::connectTo, то мы сможем найти пару следующих строчек:

void __fastcall ServerConnection::connectTo(__int64 a1, __int64 a2, __int64 a3)
{
    ...
    Messaging::setEncrypters(*(_QWORD *)(a1 + 8), 0LL, 0LL, 0LL);
    ...
    Messaging::connect();
    ...
    v11 = (SCIDManager *)GameSCIDManager::sm_pInstance;
    ServerConnection::getEnviromentName(&v12, a1);
    SCIDManager::setEnvironment(v11, &v12);
    ...
}

Ну, или в ServerConnection::disconnect

void __fastcall ServerConnection::disconnect(ServerConnection *a1, char a2)
{
  ...
  
  v3 = *((_QWORD *)a1 + 1);
  *(_DWORD *)a1 = 9;
  Messaging::disconnect(v3);
  if ( (a2 & 1) != 0 )
  {
    v5 = (MessageManager *)*((_QWORD *)a1 + 1);
    if ( v5 )
      v5 = (MessageManager *)(*(__int64 (__fastcall **)(MessageManager *))(*(_QWORD *)v5 + 8LL))(v5);
    MessageManager::destructInstance(v5);
    ServerConnection::init((__int64)a1);
  }
}

Так, стоп. Что это получается? Этот класс не работает с подключениями?

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

Перейдём к классу Messaging.

Перейдя в конструктор класса Messaging, мы можем наблюдать ещё больше месева! Какой-то asio, какой-то PepperEncrypter. Что это? Аааа! Помогите!

void __fastcall Messaging::Messaging(__int64 a1, int a2)
{
    ...
    asio::io_context::io_context(v7);
    ...
    PepperEncrypter::PepperEncrypter(v20);
    *(_QWORD *)(a1 + 336) = v20;
    PepperEncrypter::PepperEncrypter(v21);
    *(_QWORD *)(a1 + 328) = v21;
    ...
}

Спокойно! Сейчас разберёмся.

Во первых, что такое asio? Это какой-то секретный класс от разработчиков для супер секретных супер технологий???

Если обратиться к доверенным источникам (например, Wikipedia), то можно сказать что...

asio — свободно распространяемая кроссплатформенная библиотека C++ с открытым исходным кодом для сетевого программирования.

Говоря простым языком, asio - библиотека для работы с сетевыми соединениями без блокировки нашей программы. В нашем случае, нашей игры.

В данном случае, asio используется в качестве клиента. В нашем случае, это нам это не нужно, так-как мы используем модуль net.

А вот что такое PepperEncrypter?

Это - класс для работы с криптографией. Игра использует шифрование для защиты пакетов от различных модификаций с третьей стороны.

А что за Pepper? Это метод шифрования?

Нет. Игра использует NaCl (Networking and Cryptography library) в качестве метода шифрования. Данный метод шифрования обеспечивает безопасное соединение с сервером через TCP-протокол и, как я выражался выше, не даёт третьей стороне модифицировать пакеты.

Если поискать функции класса Messaging, то можно найти следующие функции:

  1. Messaging::connect
  2. Messaging::disconnect
  3. Messaging::decryptData
  4. Messaging::send(Hello/PepperLogin)
  5. Messaging::isConnected
  6. Messaging::isAuthenticated

Ага! Вот и наш класс для работы с соединением! Судя по функциям (и их коду), они отвечают за проверку подключение/отключение от сервера, расшифровку данных, отправку сообщений, проверку на подключение и... Проверку на аутентификацию? Что? А разве аутентификацию не лучше проверять в ServerConnection? А ещё, зачем создавать отдельные функции под отправку "привета" и... авторизацию перца? Чегоо?

Ну, вообще стоит поговорить ещё об одном моменте.

Для начала, в игре у классов ServerConnection и Messaging существуют состояния. Но как понять за что отвечает определённое состояние?

Надо чуть покопать!

Не отходя далеко, можно привести пример с классом Messaging:

bool __fastcall Messaging::isAuthenticated(__int64 a1)
{
    return *(a1 + 24) == 5
}

a1 - экземпляр класса Messaging.

+ 24 - состояние. По сути своей, если переводить эту функцию, получится:

Messaging.m_state == AUTHENTICATED

Или ещё вот пример с ServerConnection:

void __fastcall ServerConnection::update(__int64 a1, float a2)
{
    ...
    switch(a1) {
        case 0:
            ...
        return;
        case 1:
            ...
            *(_DWORD *)a1 = 3;
            // ClientHelloMessage setup and sending
            ...
        break;
        case 3:
             *(_DWORD *)a1 = 4;
             // guess it by yourself
             ...
        break;
        ...
        case 10:
            ...
            v16 = *(_QWORD *)(a1 + 8);
            *(_DWORD *)a1 = 9;
            Messaging::disconnect(v16);
            ServerConnection::connect((ServerConnection *)a1);
        return;
        default:
            ...
    }
    ...
}

И как можно наблюдать выше, состояние (a1) можно рассмотреть как машину состояний, где каждое значение определяет поведение этой функции при её следующем вызове.

Так, стоп. А что за функция update? Нужна ли она нам?

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

Стоит также упомянуть, что в классе Messaging есть два очень интересных массива. Зачем они?

Первый массив содержит полученные сообщения от сервера, а второй - сообщения, которые клиенту только стоит отправить. А для чего это? Нельзя сразу сообщения отправлять или получать?

Кто знает. Спроси у разработчиков. Однако, по крайней мере, одна из очередей нам точно понадобится, а именно на получение пакетов с клиента. А почему так - расспишу тогда, когда будем писать логику сервера.

Так, ладно. Перейдём дальше к LogicLaserMessageFactory

Перейдя в его конструктор, мы можем увидеть только...

LogicMessageFactory *__fastcall LogicLaserMessageFactory::LogicLaserMessageFactory(LogicMessageFactory *a1, char a2)
{
  char v3; // w20
  LogicMessageFactory *result; // x0

  v3 = a2 & 1;
  result = LogicMessageFactory::LogicMessageFactory(a1);
  *(_QWORD *)a1 = off_D60F18;
  *((_BYTE *)a1 + 8) = v3;
  return result;
}

А что за LogicMessageFactory?

Класс из движка игры. На нём особо зацикливаться не будем.

Ну и для волнующихся, a1 + 8 - это флаг, который говорит функции, о которой поговорим ниже, стоит ли клиенту создавать экземпляры UDP сообщений?

Поискав функции этого класса, можно наткнуться на...

LogicLaserMessageFactory::createMessageByType

Серьёзно, это единственная функция в данном классе. И её единственная цель - создавать экземпляры сообщений.

__int64 __fastcall LogicLaserMessageFactory::createMessageByType(__int64 a1, int a2)
{
    if ( *(a1 + 8) == 1 ) 
    {
        // UDP сообщения
    } 

    // TCP сообщения
}

Для нашего сервера, мы напишем этот класс статическим, так-как особого смысла создавать каждый раз экземпляр этого класса особого смысла не имеет.

Такс, а теперь MessageManager. Конструктор для нас бесполезен, ибо он даже не используется.

MessageManager::MessageManager не используется.

Почему? Потому что данный класс является одиночкой. И в этом можно удостовериться, если в функции MessageManager::constructInstance посмотреть на одну интересную строчку. А именно

MessageManager::sm_pInstance = v4; // v4 - MessageManager instance

У нас, он будет полноценным классом.

Ладно, сразу переходим к функциям.

  1. MessageManager::sendMessage
  2. MessageManager::receiveMessage
  3. MessageManager::startTutorial
  4. MessageManager::requestSeasonRewards

В первую очередь, в глаза бросается функция MessageManager::sendMessage. Погодите-ка... У нас же есть Messaging::send! Зачем тогда нужна эта функция?

Зайдя в неё, можно увидеть примерно это:

__int64 __fastcall MessageManager::sendMessage(__int64 a1, __int64 a2)
{
    ...
    isAuthenticated = Messaging::isAuthenticated(*(_QWORD *)(a1 + 64));
    ...
    if ( (isAuthenticated & 1) == 0 ) 
    {
         if ( a2 ) 
         {
             (*(void (__fastcall **)(__int64))(*(_QWORD *)a2 + 56LL))(a2);
             (*(void (__fastcall **)(__int64))(*(_QWORD *)a2 + 8LL))(a2);
         }
    }
    ...
}

Говоря простым языком, эта функция проверяет: Прошли ли мы аутентификацию? Если нет, то мы просто не отсылаем сообщение. Если прошли, то ниже отправляем сообщение.

А теперь MessageManager::receiveMessage... Зачем ты нужно?

Эта функция вызывается после декодировки пакета в сообщение для того, чтобы клиент (или сервер) смогли обработать полученную информацию.

Например, в случае сервера:

switch(message.getMessageType()) {
    case GET_ALLIANCE_DATA_MESSAGETYPE:
        console.log("Игрок запросил информацию о клубе с ID", message.getAllianceID().toString())

        const alliance = DatabaseManager.getAlliance(message.getAllianceID())        

        if (alliance) {
            // собираем пакет с информацией о клубе и отправляем
            console.log("Информацию о клубе", alliance.name, "отправлена!")
            return
        }
        
        console.error("Такого клуба нет!")
        break;
    ...и так далее
}

Вы можете спросить: Неужели они есь код написали в одной функции? Нельзя было разделить на разные функции?

Ну, да, они написали так. Но в некоторых моментах обработку выносят в свои менеджеры, т.п.

Дальше, MessageManager::startTutorial.

Эээ, отдельная функция для... начала туториала? Зачем?

Во первых, она вызывается в двух местах.

Функция MessageManager::startTutorial вызывается два раза в MessageManager::receiveMessage и в MessageManager::update

В MessageManager::receiveMessage она вызывается если ты только создал новый аккаунт.

А зачем в MessageManager::update?

Моя теория: Раньше, когда игра только перешла в горизонтальный вид, игрокам которые играли до перехода было необходимо пройти туториал.

Когда игрок нажимал "Play Tutorial", игра ставила в MessageManager флаг о том, что при следующем обновлении MessageManager (при следующем вызове MessageManager::update) необходимо зайти в туториал.

if ( *(_BYTE *)(a1 + 369) ) // a1 + 369 - shouldStartTutorial
{
    homeModeInstance = HomeMode::getInstance();
    HomeMode::getPlayerAvatar(homeModeInstance);
    MessageManager::startTutorial((MessageManager *)a1, v37, 1);
    *(_BYTE *)(a1 + 369) = 0;
}

Такс, а зачем MessageManager::requestSeasonRewards?

Эта функция отправляет серверу сообщение о запросе наград в испытаниях/других местах.

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

Понятия не имею.

Такс, окей. Мы прошлись по MessageManager.

Важное упоминание также требуют следующие классы:

PiranhaMessage

Это - класс, от которого наследуются все сообщения в игре. Буквально все. И клиентские, и серверные.

Если посмотреть на его конструктор, то можно увидеть что-то вроде...

__int64 __fastcall PiranhaMessage::PiranhaMessage
{
    ByteStream *v3; 

    ByteStream::ByteStream(v3);
    *(_DWORD*)(a1 + 16) = v3;
}

Говоря простым языком, ByteStream является неотъемлимой частью PiranhaMessage.

ByteStream

Вот мы и до него добрались. Данный класс необходим для записи/чтения байтов из пакетов.

Он сам наследуется от класса ChecksumEncoder, но в Brawl Stars этот класс не используется, так что можно использовать простой ByteStream.

А зачем вообще нужен ChecksumEncoder?

В других играх от Supercell (Clash of Clans, Clash Royale) он используется для проверки правильности записи пакета.

Давайте затронем некоторые главные функции ByteStream.

  • ByteStream::readVInt

Данная функция читает из буфера VarInt. Может прочитать числа от 0 до N, где N зависит от максимального количества байтов, выделенных под VarInt.

  • ByteStream::readInt

Читает 4 байта из буфера и превращает их в число. Может прочитать числа от -2 147 483 648 до 2 147 483 647.

  • ByteStream::readString

Читает длину строки и саму строку из буфера.

  • ByteStream::readBoolean

Читает Boolean из буфера. Может из одного байта прочитать 8 булеанов.

write-функции делают то же самое, только вместо чтения из буфера, записывают туда информацию.

Не отходя далеко, можем поговорить ещё про...

ByteStreamHelper

Статический класс для упрощения записи/чтения информации в буфере.

Из всех функций данного класса можно обозначить парочку важных.

  • ByteStreamHelper::readDataReference

Читает два VarInt и возвращает LogicData

Простой пример:

VarInt 1 - 28
VarInt 2 - 0

Первый VarInt является ID нашей таблицы с CSV данными, где хранятся LogicData. Второй VarInt является ID данных, находящихся в этой таблице.

28 отвечает за указатель на нужный нам экземпляр LogicDataTable, хранящий экземпляры класса LogicPlayerThumbnailData, а 0 является самим референсом на нужный нам экземплярLogicPlayerThumbnailData.

И чтобы понять что это, мы можем обратимся к файлу csv_logic/player_thumbnails.csv и взглянуть на первую строку (игнорируя первые две, ибо первая - это название столбца, а второй его тип), то мы сможем найти следующее:

base1,1,0,,,sc/ui.sc,player_icon_00,0,

Тоесть... Это самая первая иконка! В этом можно будет удостовериться чуть позже.

Упрощая, можно составить такое древо:

LogicDataTables::TABLES[]
├─ LogicDataTable::items[]
│  ├─ LogicPlayerThumbnailData { name: "base1" }
│  ├─ LogicPlayerThumbnailData { name: "base2" }
│  ├─ LogicPlayerThumbnailData { name: "base3" }
│  ├─ ...
├─ LogicDataTable::items[]
│  ├─ ...

В случае, если прочитанные данные будут некорректны, возвращается null.

Также, в случае, если в первом VarInt указан 0, то второй VarInt не читается и возвращается null.

  • ByteStreamHelper::decodeLogicLong

Данная функция читает два VarInt и складывает их в переданный в функцию экземпляр класса LogicLong

LogicLong

Тоже довольно нужный класс для удобной работы с ID аккаунтов, клубов, команд и т.д.

HomeMode

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

По сути, в игре данный класс является состоянием игры в текущий момент, которое хранится в классе-одиночке GameStateManager. И в игре существует 8 таких состояний!

Можно, конечно взять и переписать GameStateManager примерно также, как и с MessageManager, но давайте не будем настолько заморачиваться и просто будем хранить экземпляр HomeMode в доступном для нас месте. Например, в вышеупомянутом MessageManager. Может быть, в другой раз перепишем.

Раз речь зашла про HomeMode, то нам стоит поговорить и про...

LogicHomeMode

Этот класс нужен для удобной работы с данными игрока. Он находится в вышеупомянутом HomeMode.

И так, одна треть уже разобрана. Что дальше?

Структурируем информацию. Нам необходимо реализовать логику следующих классов:

  1. ClientConnection
  2. Messaging
  3. PepperEncrypter
  4. MessageManager
  5. LogicLaserMessageFactory
  6. PiranhaMessage
  7. ByteStream
  8. ByteStreamHelper
  9. HomeMode/LogicHomeMode

Также в добавок нам необходимо реализовать систему "очередей" для Messaging. Зачем?

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

Систему очередей мы будем делать в отдельном классе, поэтому добавьте в наш список класс MessageQueue

Ну а ещё для удобства работы с подключениями, добавим в наш список класс SessionManager.

Также, нам бы желательно сделать логику команд на сервере, но об этом в другой статье.

А теперь мы будем писать серверную логику!

Помните наш raw сервер в начале статьи? Давайте перепишем его на статический класс. Как минимум, для красоты кода.

Переписанный raw сервер

Кстати, вы можете спросить: А почему ты указал порт 9339? Не 8080 там, не 25565 а именно 9339?

Вообще, это стандартный порт всех игровых серверов Supercell. По крайней мере, Production и Stage серверов. И чисто для удобства мы будем использовать тот-же самый порт.

Помимо порта 9339 они используют и другие порты. Но какие - копайте сами ;)

Такс, начнём с SessionManager. Он у нас будет статический.

Мы будем хранить подключения в Map<number, ClientConnection>, где number будет значит ID подключения.

Теперь нам необходимо как-то добавлять наши подключения сюда. Но как?

Добавим два метода - addNewSession и getLastSessionId

Вкратце что оно делает:

  1. Мы получаем ID последней добавленной сессии. Если сессий нет, возвращаем 0.
  2. Этот ID мы выставляем в экземпляр класса ClientConnection (для примера, я создал пустой класс ClientConnection, перейдём к нему позже).
  3. Мы добавляем в SessionManager._sessions нашу сессию.

Такс. А что если клиент отключится? Сессия же будет висеть в нашем списке!

Тоже верно. Поэтому добавим ещё один метод: removeSession

Говоря проще, мы удаляем из SessionManager._sessions сессию игрока.

Также можно добавить метод getSessionById для получения сессии другого игрока. Так, на всякий.

Этот метод получает сессию из SessionManager._sessions по ID и возвращает нам. Если соединения с таким ID не существует, нам вернётся undefined.

Такс. SessionManager у нас создан. Переходим к MessageQueue.

Кстати, совет от меня: Структурируйте все ваши файлы по папкам. Например...

Networking/
├─ SessionManager.ts
├─ ClientConnection.ts
Logic/
├─ Avatar/
│  ├─ LogicClientAvatar.ts
├─ Mode/
│  ├─ HomeMode.ts
├─ Home/
│  ├─ Mode/
│  │  ├─ LogicHomeMode.ts
│  ├─ LogicClientHome.ts
├─ Message/
│  ├─ Auth/
│  │  ├─ LoginMessage.ts
│  │  ├─ LoginOkMessage.ts
│  │  ├─ LoginFailedMessage.ts
│  ├─ Home/
│  │  ├─ OwnHomeDataMessage.ts
│  ├─ Networking/
│  │  ├─ Messaging.ts
│  │  ├─ MessageQueue.ts
│  │  ├─ MessageManager.ts
│  ├─ LogicLaserMessageFactory.ts
Titan/
├─ DataStream/
│  ├─ ByteStream.ts
├─ PepperCrypto/
│  ├─ Library/
│  │  ├─ TweetNaCl.ts
│  ├─ PepperEncrypter.ts
├─ Message/
│  ├─ Security/
│  │  ├─ ClientHelloMessage.ts
│  │  ├─ ServerHelloMessage.ts
│  ├─ PiranhaMessage.ts

Мы будем использовать такую структуру в данном проекте.

И так, напишем наш первый метод в классе MessageQueue.

Методом push, мы суём полученные байты с клиента в буфер.

Такс... А как нам вообще понимать: Когда мы должны получить байты? Да и как вообще их получать?

Для этого напишем методы get и hasSufficientData. Ну и геттеры length с expectedLength для того, чтобы получать количество байтов в очереди

Через метод get, мы получаем наши байты.

Через геттер length мы получаем количество байтов в очереди.

Через геттер expectedLength мы получаем количество байтов, которые отправил нам клиент.

Через метод hasSufficientData мы получаем информацию о том: прилетел ли пакет полностью или нет?

Так. А что такое readUint16BE(2, 3)?

Про это поговорим чуть позже. Но забегая наперёд: это длина пакета.

Ну и также сделаем методы release и free для того, чтобы наша очередь не хранила уже обработанные пакеты.

Методом release мы убираем n-количество байтов с очереди.

Методом free мы полностью чистим очередь.

А теперь... ClientConnection!

Возвращаясь к ServerConnection, мы можем понять, что он содержит Messaging и MessageManager. Значит и мы будем делать так-же. Но с одной поправкой.

В Messaging мы будем передавать наше подключение, дабы можно было с ним удобно работать.

Поставим в ClientConnection прослушиватель события на прилёт пакетов с клиента. И если что-то прилетело, то будет вызываться колбэк ClientConnection.onReceive

И для написания onReceive, нам нужно чуть написать класс Messaging. А именно, добавить туда очередь.

Ну а также сделать метод, который вернёт нам Messaging._receiveQueue.

Ну и ещё колбэк onReceive и тут поставим. В будущем, он нам понадобится.

Такс. С Messaging мы пока закончили. Вернёмся к ClientConnection.

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

Также добавим метод destruct, который будет чистить очередь и удалять сессию из SessionManager._sessions

Ну а ещё поставим Timeout на 10 секунд и отключим задержку для мелких пакетов.

А что за задержка то?

Эта задержка вызвана тем, что в net по стандарту используется Nagle's Algorithm, который вводит задержку между отправкой мелких пакетов, дабы объединить их в один большой пакет.

В колбэке ClientConnection.onReceive мы суём данные в очередь. После чего проверяем если пакеты прилетели полностью. И если всё замечательно, получаем их через Messaging.

И теперь переходим к... Messaging

А как нам собственно понимать: какой пакет нам отправил клиент?

Нам нужно читать заголовок!

Пакеты в играх от Supercell строятся так:

  1. Заголовок (7 байт)
  2. Тело (n байт)

А как нам читать заголовок?

Погрузимся снова в дизассемблер и найдём следующие функции...

Ага. Тоесть для чтения заголовка мы используем Messaging::readHeader, а для записи - Messaging::writeHeader. Так?

Да, всё так. Заглянем в эту функцию...

Мы можем использовать методы из Buffer для декодирования заголовка.

А ещё: Мы чуть поменяем функцию, дабы она возвращала нам декодированный заголовок в объекте.

Переходим в Messaging и пишем следующий код.

И теперь в Messaging.nextMessage...

Отлично. Теперь заголовок у нас декодируется. Остаётся сделать дешифрацию пакета и его обработку в сообщении.

Теперь нам необходимо реализовать PepperEncrypter. Однако, это я оставлю на размышление вам. Покажу только то, что там должно находиться.

Так-как сообщение у нас зашифровано, нам необходимо прописать также метод Messaging.decryptData. Но опять же повторяюсь, всё это лежит на вас.

После дешифрации пакета, нам нужно создать экземпляр сообщения, декодировать его и передать в MessageManager.receiveMessage. Но сделаем мы это чуть позже. Для начала, надо реализовать создание экземпляра сообщения.

И в этом нам поможет LogicLaserMessageFactory. Но для начала нам нужно сделать ByteStream и PiranhaMessage. ByteStreamHelper распишем позже.

Расписывать реализацию каждого метода - долго. Поэтому оставляю это на вас.

А теперь PiranhaMessage.

Так как этот класс является главным для сообщений, мы пропишем здесь некоторые методы, некоторые из которых потом будем оверлоадить в других пакетах.

  1. encode
  2. decode
  3. getMessageType
  4. getEncodingLength
  5. getMessageVersion
  6. getByteStream
  7. getMessageBytes
  8. isClientToServerMessage
  9. isServerToClientMessage
  10. free

Опишем каждый метод.

  • encode

Метод, который будет вызываться для кодирования вашей информации в байты для последующей отправки клиенту.

  • decode

Метод, который будет вызываться для декодирования байтов с клиента для дальнейшей обработки сервером.

  • getMessageType

Возвращает тип сообщения.

Вообще, я видел, что кто-то устанавливает его в свойство класса. Почему? Неизвестно.

  • getEncodingLength

Возвращает длину сообщения (без учёта заголовка).

По сути, упрощение message.getByteStream().getByteArray().length или message.getByteStream().getLength()

  • getMessageVersion

Возвращает версию сообщения.

  • getByteStream

Возвращает экземпляр класса ByteStream, принадлежащий экземпляру класса сообщения.

  • getMessageBytes

Возвращает байты сообщения.

По сути, упрощение message.getByteStream().getByteArray()

  • isClientToServerMessage

Возвращает true если сообщение направлено от клиента серверу.

  • isServerToClientMessage

Возвращает true если сообщение направлено от сервера клиенту.

  • free

Освобождаем буфер в ByteStream.

Такс. PiranhaMessage у нас есть. Давайте напишем наше первое сообщение. А именно - ClientHelloMessage.

Создаём файл и пишем туда...

И так. Пакет ClientHelloMessage имеет тип сообщения 10100. В теле пакета он хранит:

  1. Версия протокола игры (Int)
  2. Версию ключа шифрования (Int)
  3. Мажор (Int)
  4. Билд (Int)
  5. Минор (Int)
  6. Хэш из fingerprint.json (String)
  7. Тип устройства (Int)
  8. Тип магазина, откуда была установлена игра (Int)

Получается, что нам нужно переписать decode и getMessageType. Да запросто!

Обратите внимание, что в decode и encode функциях мы обращаемся к экземпляру ByteStream через this.stream!

Отлично! Мы написали самый первый пакет! А что дальше?

А теперь нам надо написать LogicLaserMessageFactory. Довольно простой класс, на самом деле.

Чётко! Теперь мы можем вернуться к Messaging.

Теперь мы создаём экземпляр сообщения, суём в него расшифрованные байты и декодируем!

Если мы ещё не написали сообщение для работы с этим пакетом, то возвращаем null.

Такс. Теперь переходим в MessageManager. Будем писать обработку сообщений.

Скажу сразу: Мы не будем писать также, как оно есть в коде игры. Мы сделаем отдельные приватные методы для обработки каждого сообщения. Окей?

Окей. Мы сделали чтение клиентских пакетов и сделали нормальную работу с ними! А как нам сделать отправку серверных пакетов?

Тоже довольно просто. Переходим в Messaging, пишем методы send и writeHeader.

В оригинале, Messaging::writeHeader записывает заголовок в буфер, который передаёт ему игра. Мы, опять же, чуть изменим эту функцию и она будет нам возвращать просто буфер с записанными данными.

Теперь пишем метод send.

И так. Здесь мы проверяем: Не было ли кодирование сообщения ранее? Если не было, то кодируем.

Дальше мы шифруем байты (опять же, это лежит на вас), создаём заголовок для сообщения, совмещаем его с зашифрованными байтами и отправляем клиенту!

Теперь идём обратно в MessageManager и пишем метод sendMessage.

Как мы уже говорили ранее, данная функция не даёт отправляться сообщениям пока мы не пройдём аутентификацию. В целом, на сервере необязательно добавлять эту проверку, так-как, условно, мы можем отправить сообщение с ошибкой об авторизации незашифрованным ещё до того, как мы отправим ServerHelloMessage. Кстати о нём.

Сообщение ServerHelloMessage содержит тип сообщения 20100 и может хранить в своём теле только randombytes, который, кстати, нужен для PepperCrypto.

Ага. Тоесть единственное, что нам нужно поменять - encode и, опять же, getMessageType!

Но для начала, нам бы написать само сообщение.

Тут можно задать вопрос: А что такое writeBytes?

Это тоже метод класса ByteStream. И работает он примерно так-же как и writeString. Только в отличии от него, мы вписываем вместо строки - байты.

Готово! Наше первое серверное сообщение сделано! А что дальше?

Заглянув в код игры, можно найти функцию Messaging::sendHello. Она отправляет собранный клиентом в ServerConnection::update - ClientHelloMessage.

Но давайте адаптируем эту функцию под наши нужды.

  1. Мы будем генерировать и вставлять randombytes прямо в функции.
  2. Мы будем создавать экземпляр сообщения там же. Ну и отправлять, да.

Вот так просто. Теперь, мы можем отправлять ответ на ClientHelloMessage. Как? Вот так!

Отлично! Теперь у нас есть самая примитивная логика сервера на диком западе. И для полноценной картины мира сделаем быстренько LoginMessage и LoginOkMessage. Но перед этим по-быстрому наклипаем классы LogicLong и ByteStreamHelper

Для начала, LogicLong. Вы же помните, зачем этот класс нужен?

Он хранит в себе 2 числа.

Так, стоп. А зачем нам тут encode/decode? Разве это - сообщение?

Ну, это сделано для удобства. Дабы каждый раз не писать

this.stream.writeInt(logicLong.high)
this.stream.writeInt(logicLong.low)

Можно просто...

this.accountId.decode(this.stream)

А теперь - ByteStreamHelper.

Однако, эти write/readDataReference, скажем так, неполноценны. Почему?

Мы не сделали LogicData. А неужели это так важно?

В целом, да. Если вы хотите удобно работать с ассетами игры у себя на сервере, то иметь LogicData просто необходимо. Можем поговорить об этом в другой статье, ибо здесь - мы делаем примитивный сервер.

Так, возвращаемся к LoginMessage и LoginFailedMessage.

Начнём с первого.

LoginMessage имеет тип сообщения 10101 и содержит... Ого как много чего!

Но давайте пока перечислим основные поля.

  1. ID аккаунта (если вы уже заходили до этого на сервер в лобби. В ином случае - 0, 0) (LogicLong || 2 Ints)
  2. Токен от аккаунта (String)
  3. Мажор (Int)
  4. Билд (Int)
  5. Минор (Int)
  6. Хэш из fingerprint.json (string)
  7. Имя устройства (string)
  8. Установленная в клиенте локализация (DataReference || 2 VarInts)
  9. Язык устройства (string)
  10. Версия ОС (string)

Мы разобрали эту структуру не полностью. Если хотите - можете разобрать до конца.

А теперь LoginFailedMessage.

Он отвечает за причину почему сервер не смог авторизировать клиент. Например, сейчас технический перерыв, или нужно обновить какие-то ассеты

LoginFailedMessage содержит тип сообщения 20103 и... тоже имеет множество полей. Но мы разберём самые основные

  1. Код ошибки (Int)
  2. Данные Fingerprint (String)
  3. Домен для редиректа (String)
  4. Хост откуда игра должна скачать контент (String)
  5. Ссылка на обновление, если таковое требуется. (String)
  6. Причина ошибки (String)

А теперь немного поговорим про код ошибки.

В зависимости от того, что вы туда выставите, игра поведёт себя по разному. Например:

  • Если выставить код ошибки 1, то игра выведет текст, указанный в LoginFailedMessage.reason
  • Или если выставить код ошибки 10, то игра выведет окно, которое говорит что сейчас на сервере проходит технический перерыв.

Вы можете сами поэкспериментировать с кодами ошибков и взять какие-то коды на заметку.

Такс. А как LoginFailedMessage отправлять нам?

Тоже просто.

Но давайте сначала добавим LoginMessage в LogicLaserMessageFactory и создадим для него обработчик в MessageManager

Смотрите, показываю один раз.

По сути, мы также, как и в Messaging.sendHello создаём экземпляр сообщения, вбиваем туда нужные нам данные и отправляем клиенту.

И давайте теперь быстро пробежимся по HomeMode и LogicHomeMode и к ним прилегающим классам.

HomeMode у нас будет находиться в MessageManager, как было сказано выше. Однако, если мы будем затрагивать другие состояния, то мы сделаем полноценный GameStateManager, кто знает?

Я по быстрому накидал классы HomeMode и LogicHomeMode. Поэтому объясню быстро.

Мы храним в HomeMode экземпляр класса MessageManager для отправки OwnHomeDataMessage (24101) и, в дальнейшем затронем, AvailableServerCommandMessage (24111)

LogicHomeMode хранит в себе LogicClientHome и LogicClientAvatar для удобной работы с информацией об игроке.

LogicClientHome хранит в себе основную информацию об игроке и некоторые настройки для клиента. Также он хранит экземпляр LogicHomeMode.

LogicDailyData хранит в себе информацию о Brawl Pass, магазине, вашем прогрессе, скинах и т.д.

LogicConfData хранит в себе информацию о различных вещах, которые можно в дальнейшем обновить вызовом команды LogicDayChangedCommand (204) (события, вербовка бойцов, т.п)

LogicClientAvatar хранит информацию о вас, ваш ID, ваших персонажей, их состояния, ваши сбережения и т.д. Также он хранит экземпляр LogicHomeMode.

Большая часть информации в HomeMode и подклассах хранится по классам. Если так прикинуть, можно составить такое дерево

HomeMode
└── LogicHomeMode
    ├── LogicClientHome
    │   ├── LogicDailyData
    │   │   ├── ForcedDrops
    │   │   ├── TimedOffer
    │   │   ├── TimedOffer
    │   │   ├── LogicOfferBundle[]
    │   │   │   ├── LogicGemOffer[]
    │   │   │   └── ChronosTextEntry
    │   │   ├── AdStatus[]
    │   │   ├── IntValueEntry[]
    │   │   ├── CooldownEntry[]
    │   │   ├── BrawlPassSeasonData[]
    │   │   │   ├── LogicBitList
    │   │   │   ├── LogicBitList
    │   │   ├── ProLeagueSeasonData[]
    │   │   ├── LogicQuests
    │   │   │   └── QuestData
    │   │   ├── VanityItems
    │   │   │   └── VanityItemEntry[]
    │   │   │       └── VanityItemProp[]
    │   │   └── LogicPlayerRankedSeasonData
    │   │       └── LogicPlayerRewardData[]
    │   │           └── LogicRewardConfig[]
    │   │               ├── LogicCondition
    │   │               └── LogicGemOffer
    │   ├── LogicConfData
    │   │   ├── EventSlot[]
    │   │   ├── EventData[]
    │   │   │   ├── LogicRankedSeason
    │   │   │   ├── ChronosTextEntry
    │   │   │   ├── ChronosTextEntry
    │   │   │   ├── LogicGemOffer
    │   │   │   └── ChronosFileEntry
    │   │   ├── EventData[]
    │   │   │   ├── LogicRankedSeason
    │   │   │   ├── ChronosTextEntry
    │   │   │   ├── ChronosTextEntry
    │   │   │   ├── LogicGemOffer
    │   │   │   └── ChronosFileEntry
    │   │   ├── ReleaseEntry[]
    │   │   ├── IntValueEntry[]
    │   │   ├── TimedIntValueEntry[]
    │   │   └── CustomEvent[]
    │   │       ├── ChronosTextEntry
    │   │       ├── ChronosTextEntry
    │   │       └── ChronosTextEntry
    │   ├── BaseNotification[]
    │   └── GatchaDrop[]
    └── LogicClientAvatar
        └── LogicDataSlot[][]

И так. После удачной авторизации, мы создаём новый экземпляр класса HomeMode и вызываем метод enter

Затем, происходит прогрузка значений по местам и отправка сообщения OwnHomeDataMessage клиенту. Так. А что это за сообщение?

OwnHomeDataMessage имеет тип сообщения 24101 и содержит... Множество полей.

Этим сообщением мы говорим клиенту о том, что вся информация на сервере уже прогружена, остаётся только клиенту её тоже прогрузить.

И так, мы создали самую базовую логику для сервера (вход в лобби). Что дальше?

А дальше клиент.

Если мы посмотрим на функцию ServerConnection::connect, то из всего мы сможем увидеть следующее:

void __fastcall __noreturn ServerConnection::connect(ServerConnection *a1)
{   
    ...
     String::String(v15, "game.brawlstarsgame.com");
     String::String(v14, "9339");
     ServerConnection::connectTo(a1, v15, v14);
    ...
    
}

Ага! Нам нужно как-то поменять адрес хоста!

Верно. И здесь можно пойти двумя путями:

  • Патчить libg.so

Довольно эффективный метод, дабы не затрагивать ничего особо важного. Но и у этого метода есть свои подводные камни.

  1. Хост под длине не должен превышать длину строки game.brawlstarsgame.com
  2. Необходимо расшифровать для начала эту строку, а затем только вносить данные. Ну и опять шифровать, да.
  3. Нельзя будет, в случае чего внести свои изменения в клиент. Например, разблокировать FPS, или что-то в этом роде.

Поэтому, мы будем...

  • Использовать Frida

И так. Нам необходимо как-то менять эту строку. Можно, как вариант, сделать так:

const base = Module.findBaseAddress("libg.so");

const hostStringPtr = 0xDEADC0DE

base.add(hostStringPtr).writeUtf8String("НАШ_IP_АДРЕС")

Но так игра будет вести себя нестабильно. Давайте думать лучше...

А можем ли мы поменять аргументы в функции ServerConnection::connectTo?

Да, можем! И для этого мы можем использовать Interceptor.attach!

const base = Module.findBaseAddress("libg.so");

const ourHostString = Memory.allocUtf8String("0.0.0.0")
const ServerConnection_connectToPtr = 0xDEADC0DE

Interceptor.attach(base.add(ServerConnection_connectToPtr), {
    onEnter(args) {
         args[1] = ourHostString
    }
})

Однако, при попытке запустить наш клиент с этим скриптом, клиент не подключается к нашему серверу, хотя мы указали всё верно. Почему?

Видите ли, разработчики хранят некоторые строки в экземплярах класса String. Зачем? Не особо ясно. Им виднее.

Так как мы делаем примитивный сервер, а значит и клиент, то мы не будем реализовывать логику создания подобных строк. А как тогда?

Дам небольшую подсказку: getaddrinfo

И что мне даёт эта подсказка?

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

Функция getaddrinfo() возвращает одну или несколько структур addrinfo, каждая из которых содержит интернет-адрес, который можно использовать в вызове bind(2) или connect(2).

Тоесть, getaddrinfo возвращает игре addrinfo, который она может использовать для подключения к серверу! Удобно, не так ли?

Возникает вопрос: А можем ли мы поменять аргументы и в этой функции?

Да, можем. По такому же примеру выше.

const base = Module.findBaseAddress("libg.so");

const ourIpAddress = "0.0.0.0"
const getaddrinfo = Module.findExportByName(null, "getaddrinfo")

Interceptor.attach(getaddrinfo, {
    onEnter(args) {
         if (args[1].readUtf8String() === "9339") {
             args[0].writeUtf8String(ourIpAddress) // Подменяем первый аргумент на наш IP адрес.
             // Также, можно поменять и порт, только вместо args[0] нам надо писать args[1]
         }
    }
})

А что значит null в Module.findExportByName?

Если мы передаём null в эту функцию первым аргументом, Frida подставляет первую загруженную в игру библиотеку. И, вот так совпадение, в игре ей является libc.so

Такс. Вроде скрипт работает, но... Стоп, что за? Почему вылетело?

А вот и первые смешнявки от игры. Так называемые "защиты".

До версии 49.175 использовала защиту под названием Arxan. После выхода версии 49.175 игра использует Promon Shield.

Если вы разрабатываете сервер на версии после 49.175 и у вас ничего не вылетает, то поздравляю, у вас уже убиты защиты.

Однако, нам нужно разобраться с Arxan. И, на самом деле, это довольно просто.

Для того, чтобы убить одну из проверок защиты Arxan, вам необходимо поставить jump инструкцию на B инструкцию.

Пример:

const Protection_START = base.add(0xDEAD)
const Protection_END = base.add(0xC0DE)

Memory.patchCode(Protection_START, Process.pageSize, (code) => {
    const writer = new Arm64Writer(code, {
        pc: Protection_START
    });
            
    writer.putBranchAddress(Protection_END);
    writer.flush();
});

И давайте для примера возьмём LoginMessage::encode. Там как-раз таки существует такая проверочка.

Вот тут мы можем отпатчить нашу инструкцию. Protection_START равен 0x483D00

Теперь надо найти Protection_END

По сути, вам необходимо найти чистый кусок кода где начинается уже то, что по сути должна выполнять функция.

Нашли! Protection_END равен 0x484B08!

Берём наш пример выше и вписываем наши адреса.

const LoginMessage_Protection_START = base.add(0x483D00)
const LoginMessage_Protection_END = base.add(0x484B08)

Memory.patchCode(LoginMessage_Protection_START, Process.pageSize, (code) => {
    const writer = new Arm64Writer(code, {
        pc: LoginMessage_Protection_START
    });
            
    writer.putBranchAddress(LoginMessage_Protection_END);
    writer.flush();
});

Отлично! Одна из проверок убита!

Стоп... Что значит "одна из проверок?"

Да. По всей игре разбросаны эти проверки. Но то, где их обходить - оставлю на ваше раздрумие. Но могу кое-какой совет дать: Смотрите в logcat, если игра буквально с ничего вылетела.

Также, я бы вам посоветовал обойти lv1 проверки и некоторые AntiCheat функции. Так как они вполне себе тоже могут вызывать вылеты.

Окей. Я всё обошёл. В игру всё ещё не заходит.

Вторая подлянка от игры: проверка подписи.

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

Такс, я обошёл и её. В игру заходит, но клиент не подключается к серверу! Да что такое! Может быть, то, что мы писали выше для подмены хоста вообще и не работает?

Всё работает. Это просто третья подлянка от игры - проверка на localhost.

Обойти её тоже нетрудно. Но это я оставляю на вас. Могу дать вот такой код для обхода. Вам остаётся просто найти адрес.

Interceptor.attach(base.add(LocalhostCheckPtr), function() {
    this.context.PUT_YOUR_REGISTER1 = ptr(0x22);
    this.context.PUT_YOUR_REGISTER2 = ptr(0x22)
})

Клиент подключился, шлёт пакеты, но... Сервер не может расшифровать LoginMessage! Что за?

А это уже криптография. И как её делать на стороне клиента - оставляю на ваши раздумия. Можно, конечно, её отключить, но не стоит.

Отлично! Мы всё обошли и теперь у нас клиент успешно подключился к серверу и у нас ничего не вылетает! Что же дальше?

А дальше... А дальше вам остаётся писать логику, вводить новые функции и в целом изучать работу игры.

Пару важных советов:

  • Используйте криптографию.

Как я уже говорил, это не даст третьей стороне модифицировать пакеты. А также предотвратит большую часть флуда от скрипткидов.

  • Тестируйте сервер на ошибки (или наймите штаб тестеров).

Представим, что вы пишете логику покупки акций в магазине. Что можно протестировать тут?

  1. Корректное списание валюты
  2. Та ли валюта списывается?
  3. Будет ли обработана покупка акции, если у игрока на сервере недостаточно валюты?
  4. Выдаётся ли игроку всё что есть в акции?

И ещё кучу примеров.

  • Сервер не должен верить клиенту.

Это даже проработано самим протоколом игры.

И, если брать пример тестов выше, то сервер не должен верить покупке игрока, если он видит, что валюты у игрока недостаточно.

На этом всё. Огромное спасибо за чтение данной статьи. Надеюсь она будет вам хоть сколько то полезна.

Мой блог: https://t.me/tjsblog

SurgeBrawl: https://t.me/surgeoffline