Как создать свой приватный сервер на Brawl Stars?
Многие дети задумываются в настоящее время:
А как я могу создать свой приватный сервер по Brawl Stars?
... и бегут качать слитые сурсы серверов. Да. Но это плохо.
Как минимум, скачивая уже готовый сервер и просто модифицируя готовый клиент вы ничему не научитесь.
Конечно, вы можете полезть в код, дабы поменять что-то (например, текст в главном меню), но точно ли это будет ваш сервер? Чем вы будете гордиться перед одноклассниками? Что пришли на всё готовое? Неет, так не пойдёт.
Ну чтож, дед сегодня научит вас писать самый простейший сервер с клиентом и даст важные советы. Начнём!
Вам понадобится...
- Компьютер (99% школьников дальше могут не читать)
- Знания любого языка программирования, который может работать с TCP-соединениями и поддерживает работу с сокетами.
- Хотя бы базовые знания реверс-инженеринга
- Дизассемблер (IDA Pro, Ghidra, Binary Ninja, т.п.)
- Frida (будем писать скрипты)
- Редактор кода (можно даже блокнот использовать)
Предисловие
В данном гайде будет использоваться язык программирования TypeScript (по сути JavaScript, но с классными рофлами) и дизассеблер IDA Pro.
Мы будем называть пакетами - ответ, полученный/отправленный серверу/клиенту, а сообщения - экземпляры классов для работы с пакетом.
Для удобства читателя, мы будем использовать дизассемблированный код от ARM64 версии 36.218 (Лето с Динозаврами!), так-как там присутствуют дебаг-символы (названия функций).
Для начала, что нам надо сделать?
Мы напишем простенькую логику сервера, дабы наш будущий сервер смог хотя бы получать и слать пакеты.
Для начала, нам нужно написать raw сервер (по сути просто пустышка). И для этого, мы будем использовать модуль net
Отлично! Наш сервер может получать подключения от клиента. Но вот пакеты получать пока не умеет. Давайте будем писать логику так, как (в теории) пишут разработчики (нет, говнокодить не будем. возможно).
И здесь нам необходимо немного погрузиться в дизассемблер и поизучать код.
По сути, самый главным классом, отвечающим за работу клиента с сервером является 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
Поискав функции в поиске, мы можем найти следующие функции:
- ServerConnection::connect/ServerConnection::connectTo
- ServerConnection::disconnect
- ServerConnection::saveAccount
- ServerConnection::resetAccount
- 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, мы можем наблюдать ещё больше месева! Какой-то 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
, то можно найти следующие функции:
Messaging::connect
Messaging::disconnect
Messaging::decryptData
Messaging::send(Hello/PepperLogin)
Messaging::isConnected
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; }
Класс из движка игры. На нём особо зацикливаться не будем.
Ну и для волнующихся, a1 + 8 - это флаг, который говорит функции, о которой поговорим ниже, стоит ли клиенту создавать экземпляры UDP сообщений?
Поискав функции этого класса, можно наткнуться на...
LogicLaserMessageFactory::createMessageByType
Серьёзно, это единственная функция в данном классе. И её единственная цель - создавать экземпляры сообщений.
__int64 __fastcall LogicLaserMessageFactory::createMessageByType(__int64 a1, int a2) { if ( *(a1 + 8) == 1 ) { // UDP сообщения } // TCP сообщения }
Для нашего сервера, мы напишем этот класс статическим, так-как особого смысла создавать каждый раз экземпляр этого класса особого смысла не имеет.
Такс, а теперь MessageManager
. Конструктор для нас бесполезен, ибо он даже не используется.
Почему? Потому что данный класс является одиночкой. И в этом можно удостовериться, если в функции MessageManager::constructInstance
посмотреть на одну интересную строчку. А именно
MessageManager::sm_pInstance = v4; // v4 - MessageManager instance
У нас, он будет полноценным классом.
Ладно, сразу переходим к функциям.
MessageManager::sendMessage
MessageManager::receiveMessage
MessageManager::startTutorial
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::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
.
Данная функция читает из буфера VarInt. Может прочитать числа от 0 до N, где N зависит от максимального количества байтов, выделенных под VarInt.
Читает 4 байта из буфера и превращает их в число. Может прочитать числа от -2 147 483 648 до 2 147 483 647.
Читает длину строки и саму строку из буфера.
Читает Boolean из буфера. Может из одного байта прочитать 8 булеанов.
write
-функции делают то же самое, только вместо чтения из буфера, записывают туда информацию.
Не отходя далеко, можем поговорить ещё про...
ByteStreamHelper
Статический класс для упрощения записи/чтения информации в буфере.
Из всех функций данного класса можно обозначить парочку важных.
Читает два VarInt и возвращает LogicData
Первый 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
.
Данная функция читает два VarInt и складывает их в переданный в функцию экземпляр класса LogicLong
LogicLong
Тоже довольно нужный класс для удобной работы с ID аккаунтов, клубов, команд и т.д.
HomeMode
Говоря простым языком, временное хранилище данных игрока для удобного использования далее в коде.
По сути, в игре данный класс является состоянием игры в текущий момент, которое хранится в классе-одиночке GameStateManager
. И в игре существует 8 таких состояний!
Можно, конечно взять и переписать GameStateManager
примерно также, как и с MessageManager
, но давайте не будем настолько заморачиваться и просто будем хранить экземпляр HomeMode
в доступном для нас месте. Например, в вышеупомянутом MessageManager
. Может быть, в другой раз перепишем.
Раз речь зашла про HomeMode
, то нам стоит поговорить и про...
LogicHomeMode
Этот класс нужен для удобной работы с данными игрока. Он находится в вышеупомянутом HomeMode.
И так, одна треть уже разобрана. Что дальше?
Структурируем информацию. Нам необходимо реализовать логику следующих классов:
ClientConnection
Messaging
PepperEncrypter
MessageManager
LogicLaserMessageFactory
PiranhaMessage
ByteStream
ByteStreamHelper
HomeMode/LogicHomeMode
Также в добавок нам необходимо реализовать систему "очередей" для Messaging
. Зачем?
TCP-протокол хоть и безопасен, но он не гарантирует того, что вся информация с клиента придёт в одном пакете. В основном, это связано с ограничениями сети (например, MTU).
Систему очередей мы будем делать в отдельном классе, поэтому добавьте в наш список класс MessageQueue
Ну а ещё для удобства работы с подключениями, добавим в наш список класс SessionManager
.
Также, нам бы желательно сделать логику команд на сервере, но об этом в другой статье.
А теперь мы будем писать серверную логику!
Помните наш raw сервер в начале статьи? Давайте перепишем его на статический класс. Как минимум, для красоты кода.
Кстати, вы можете спросить: А почему ты указал порт 9339? Не 8080 там, не 25565 а именно 9339?
Вообще, это стандартный порт всех игровых серверов Supercell. По крайней мере, Production и Stage серверов. И чисто для удобства мы будем использовать тот-же самый порт.
Помимо порта 9339 они используют и другие порты. Но какие - копайте сами ;)
Такс, начнём с SessionManager
. Он у нас будет статический.
Мы будем хранить подключения в Map<number, ClientConnection>
, где number
будет значит ID подключения.
Теперь нам необходимо как-то добавлять наши подключения сюда. Но как?
Добавим два метода - addNewSession
и getLastSessionId
- Мы получаем ID последней добавленной сессии. Если сессий нет, возвращаем 0.
- Этот ID мы выставляем в экземпляр класса
ClientConnection
(для примера, я создал пустой классClientConnection
, перейдём к нему позже). - Мы добавляем в
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
мы полностью чистим очередь.
Возвращаясь к 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 строятся так:
Погрузимся снова в дизассемблер и найдём следующие функции...
Ага. Тоесть для чтения заголовка мы используем Messaging::readHeader
, а для записи - Messaging::writeHeader
. Так?
Да, всё так. Заглянем в эту функцию...
Мы можем использовать методы из Buffer
для декодирования заголовка.
А ещё: Мы чуть поменяем функцию, дабы она возвращала нам декодированный заголовок в объекте.
Переходим в Messaging
и пишем следующий код.
И теперь в Messaging.nextMessage...
Отлично. Теперь заголовок у нас декодируется. Остаётся сделать дешифрацию пакета и его обработку в сообщении.
Теперь нам необходимо реализовать PepperEncrypter
. Однако, это я оставлю на размышление вам. Покажу только то, что там должно находиться.
Так-как сообщение у нас зашифровано, нам необходимо прописать также метод Messaging.decryptData.
Но опять же повторяюсь, всё это лежит на вас.
После дешифрации пакета, нам нужно создать экземпляр сообщения, декодировать его и передать в MessageManager.receiveMessage
. Но сделаем мы это чуть позже. Для начала, надо реализовать создание экземпляра сообщения.
И в этом нам поможет LogicLaserMessageFactory
. Но для начала нам нужно сделать ByteStream
и PiranhaMessage
. ByteStreamHelper
распишем позже.
Расписывать реализацию каждого метода - долго. Поэтому оставляю это на вас.
Так как этот класс является главным для сообщений, мы пропишем здесь некоторые методы, некоторые из которых потом будем оверлоадить в других пакетах.
encode
decode
getMessageType
getEncodingLength
getMessageVersion
getByteStream
getMessageBytes
isClientToServerMessage
isServerToClientMessage
free
Метод, который будет вызываться для кодирования вашей информации в байты для последующей отправки клиенту.
Метод, который будет вызываться для декодирования байтов с клиента для дальнейшей обработки сервером.
Вообще, я видел, что кто-то устанавливает его в свойство класса. Почему? Неизвестно.
Возвращает длину сообщения (без учёта заголовка).
По сути, упрощение message.getByteStream().getByteArray().length
или message.getByteStream().getLength()
Возвращает экземпляр класса ByteStream, принадлежащий экземпляру класса сообщения.
По сути, упрощение message.getByteStream().getByteArray()
Возвращает true
если сообщение направлено от клиента серверу.
Возвращает true
если сообщение направлено от сервера клиенту.
Освобождаем буфер в ByteStream
.
Такс. PiranhaMessage у нас есть. Давайте напишем наше первое сообщение. А именно - ClientHelloMessage.
И так. Пакет ClientHelloMessage имеет тип сообщения 10100. В теле пакета он хранит:
- Версия протокола игры (Int)
- Версию ключа шифрования (Int)
- Мажор (Int)
- Билд (Int)
- Минор (Int)
- Хэш из fingerprint.json (String)
- Тип устройства (Int)
- Тип магазина, откуда была установлена игра (Int)
Получается, что нам нужно переписать decode
и getMessageType
. Да запросто!
Обратите внимание, что в decode и encode функциях мы обращаемся к экземпляру ByteStream через this.stream
!
Отлично! Мы написали самый первый пакет! А что дальше?
А теперь нам надо написать LogicLaserMessageFactory
. Довольно простой класс, на самом деле.
Чётко! Теперь мы можем вернуться к Messaging
.
Теперь мы создаём экземпляр сообщения, суём в него расшифрованные байты и декодируем!
Если мы ещё не написали сообщение для работы с этим пакетом, то возвращаем null
.
Такс. Теперь переходим в MessageManager
. Будем писать обработку сообщений.
Скажу сразу: Мы не будем писать также, как оно есть в коде игры. Мы сделаем отдельные приватные методы для обработки каждого сообщения. Окей?
Окей. Мы сделали чтение клиентских пакетов и сделали нормальную работу с ними! А как нам сделать отправку серверных пакетов?
Тоже довольно просто. Переходим в Messaging
, пишем методы send
и writeHeader
.
В оригинале, Messaging::writeHeader
записывает заголовок в буфер, который передаёт ему игра. Мы, опять же, чуть изменим эту функцию и она будет нам возвращать просто буфер с записанными данными.
И так. Здесь мы проверяем: Не было ли кодирование сообщения ранее? Если не было, то кодируем.
Дальше мы шифруем байты (опять же, это лежит на вас), создаём заголовок для сообщения, совмещаем его с зашифрованными байтами и отправляем клиенту!
Теперь идём обратно в MessageManager
и пишем метод sendMessage
.
Как мы уже говорили ранее, данная функция не даёт отправляться сообщениям пока мы не пройдём аутентификацию. В целом, на сервере необязательно добавлять эту проверку, так-как, условно, мы можем отправить сообщение с ошибкой об авторизации незашифрованным ещё до того, как мы отправим ServerHelloMessage
. Кстати о нём.
Сообщение ServerHelloMessage
содержит тип сообщения 20100 и может хранить в своём теле только randombytes
, который, кстати, нужен для PepperCrypto
.
Ага. Тоесть единственное, что нам нужно поменять - encode
и, опять же, getMessageType
!
Но для начала, нам бы написать само сообщение.
Тут можно задать вопрос: А что такое writeBytes
?
Это тоже метод класса ByteStream
. И работает он примерно так-же как и writeString
. Только в отличии от него, мы вписываем вместо строки - байты.
Готово! Наше первое серверное сообщение сделано! А что дальше?
Заглянув в код игры, можно найти функцию Messaging::sendHello
. Она отправляет собранный клиентом в ServerConnection::update
- ClientHelloMessage
.
Но давайте адаптируем эту функцию под наши нужды.
- Мы будем генерировать и вставлять
randombytes
прямо в функции. - Мы будем создавать экземпляр сообщения там же. Ну и отправлять, да.
Вот так просто. Теперь, мы можем отправлять ответ на ClientHelloMessage
. Как? Вот так!
Отлично! Теперь у нас есть самая примитивная логика сервера на диком западе. И для полноценной картины мира сделаем быстренько LoginMessage
и LoginOkMessage
. Но перед этим по-быстрому наклипаем классы LogicLong
и ByteStreamHelper
Для начала, LogicLong
. Вы же помните, зачем этот класс нужен?
Так, стоп. А зачем нам тут encode
/decode
? Разве это - сообщение?
Ну, это сделано для удобства. Дабы каждый раз не писать
this.stream.writeInt(logicLong.high) this.stream.writeInt(logicLong.low)
this.accountId.decode(this.stream)
Однако, эти write
/readDataReference
, скажем так, неполноценны. Почему?
Мы не сделали LogicData
. А неужели это так важно?
В целом, да. Если вы хотите удобно работать с ассетами игры у себя на сервере, то иметь LogicData
просто необходимо. Можем поговорить об этом в другой статье, ибо здесь - мы делаем примитивный сервер.
Так, возвращаемся к LoginMessage
и LoginFailedMessage
.
LoginMessage
имеет тип сообщения 10101 и содержит... Ого как много чего!
Но давайте пока перечислим основные поля.
- ID аккаунта (если вы уже заходили до этого на сервер в лобби. В ином случае - 0, 0) (LogicLong || 2 Ints)
- Токен от аккаунта (String)
- Мажор (Int)
- Билд (Int)
- Минор (Int)
- Хэш из fingerprint.json (string)
- Имя устройства (string)
- Установленная в клиенте локализация (DataReference || 2 VarInts)
- Язык устройства (string)
- Версия ОС (string)
Мы разобрали эту структуру не полностью. Если хотите - можете разобрать до конца.
Он отвечает за причину почему сервер не смог авторизировать клиент. Например, сейчас технический перерыв, или нужно обновить какие-то ассеты
LoginFailedMessage
содержит тип сообщения 20103 и... тоже имеет множество полей. Но мы разберём самые основные
- Код ошибки (Int)
- Данные Fingerprint (String)
- Домен для редиректа (String)
- Хост откуда игра должна скачать контент (String)
- Ссылка на обновление, если таковое требуется. (String)
- Причина ошибки (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); ... }
Ага! Нам нужно как-то поменять адрес хоста!
Верно. И здесь можно пойти двумя путями:
Довольно эффективный метод, дабы не затрагивать ничего особо важного. Но и у этого метода есть свои подводные камни.
- Хост под длине не должен превышать длину строки
game.brawlstarsgame.com
- Необходимо расшифровать для начала эту строку, а затем только вносить данные. Ну и опять шифровать, да.
- Нельзя будет, в случае чего внести свои изменения в клиент. Например, разблокировать FPS, или что-то в этом роде.
И так. Нам необходимо как-то менять эту строку. Можно, как вариант, сделать так:
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! Что за?
А это уже криптография. И как её делать на стороне клиента - оставляю на ваши раздумия. Можно, конечно, её отключить, но не стоит.
Отлично! Мы всё обошли и теперь у нас клиент успешно подключился к серверу и у нас ничего не вылетает! Что же дальше?
А дальше... А дальше вам остаётся писать логику, вводить новые функции и в целом изучать работу игры.
Как я уже говорил, это не даст третьей стороне модифицировать пакеты. А также предотвратит большую часть флуда от скрипткидов.
Представим, что вы пишете логику покупки акций в магазине. Что можно протестировать тут?
- Корректное списание валюты
- Та ли валюта списывается?
- Будет ли обработана покупка акции, если у игрока на сервере недостаточно валюты?
- Выдаётся ли игроку всё что есть в акции?
Это даже проработано самим протоколом игры.
И, если брать пример тестов выше, то сервер не должен верить покупке игрока, если он видит, что валюты у игрока недостаточно.
На этом всё. Огромное спасибо за чтение данной статьи. Надеюсь она будет вам хоть сколько то полезна.
Мой блог: https://t.me/tjsblog
SurgeBrawl: https://t.me/surgeoffline