Мы прошли долгий путь от появления в игре первых читеров до полного пересмотра подхода к разработке, чтобы создавать защищенные по умолчанию мобильные проекты. О том, как в игре появились читеры, я рассказал в прошлом материале. И там же привел список подзадач, которые выкатили одновременно, чтобы закрыть вопрос со взломами, — от обфускации кода до подсчета хеша всех библиотек и надежной системы бана.
Итак, эти шаги:
-
Обфускация.
-
Хранение данных.
-
Миграция прогресса.
-
Система бана.
-
Подсчет хеша всех библиотек.
-
Защита от переподписывания версий.
-
Photon Plugin.
-
Серверная валидация инапов.
-
Защита от взлома оперативной памяти.
-
Собственная аналитика.
-
И одновременный релиз всех решений.
Сегодня поговорим про первые пять пунктов.
Для удобства я пронумеровал все разделы, но напомню, что приведенные ниже решения мы разрабатывали и выкатили одновременно. Потому что постепенный ввод изменений сильно облегчит взломщикам отслеживание апдейтов.
Шаг №1. Обфускация
Первым делом ввели новые стандарты написания кода, позволяющие плагину обфускации сделать свое дело, и переписали под них всё приложение. Задача не особо сложная, но ресурсоемкая, потому что кодовая база к этому времени уже была довольно большая.
Для защиты приложения от взлома обфускация играет большую роль, так как на всех платформах можно изменять код приложения, подменяя результаты выполнения различных свойств и методов тем или иным способом. И чем проще он читается, тем ниже порог для входа. Цена инапов, жизни, урон скорость и так далее — все это нужно обязательно скрывать.
Мы используем плагин Beebyte Obfuscator. Но для работы плагинов обфускаторов надо придерживаться определенных правил оформления кода, иначе названия свойств и методов не будут скрываться.
Какие-то из приведенных ниже правил были выведены изучением материалов о работе обфускаторов, что-то получили экспериментальным путем, ну а что-то нам подсказал разработчик плагина, с которым мы связывались при внедрении. Наш набор:
-
internal вместо **public** и **private** или **internal** вместо **protected**.
-
Все члены классов, помеченных [Serializable], не обфусцируются.
-
Свести к минимуму использование Parse/ToString для enum (при обфускации результат, как правило, бесполезен), но если это все-таки необходимо, то помечаем атрибутом **[Obfuscation(Exclude = true)]**.
Например:
[Obfuscation(Exclude = true)]
public enum GameEventItemContainerContentType
{
None = 0,
SingleItem = 1,
ItemsCollection = 2,
Start = 3,
}
-
По возможности заменяем `const` на `static`. Статики нельзя использовать в switch. Например, вместо internal const string A_B = "my_constant" делать internal static readonly string A_B = "my_constant" либо internal static string A_B { get{...} }.
-
События анимаций — их нужно оставлять/делать public или помечать атрибутом [Obfuscation(Exclude = true)].
-
Лямбды — имя лямбды включает имя того метода/класса, в котором она определена, вне зависимости от модификатора доступа метода или класса. По возможности нужно заменять лямбды методами.
-
Kорутины в IL не обфусцируются. Поэтому их можно обфусцировать вручную, например, назвать vfg45_00.
Сейчас, когда под плагин переделано абсолютное большинство нашего кода, весь новый мы пишем согласно этим правилам. Также периодически пересматриваем дамп сборки со списком методов в проекте на предмет, нет ли каких-то важных необфусцированных методов.
Отмечу минус использования обфускации — время сборки неминуемо увеличивается (у нас примерно на 30%), но польза несравнимо выше.
На случай, когда надо получить сборку максимально быстро, мы добавили на билд-сервере возможность сборки без обфускации. Но обычно стараемся собирать с ней, потому что есть опасность пропустить баги связанные с обфускацией (обычно это места, где результат выполнения кода зависит от имени метода или энума).
Шаг №2. Хранение данных
При реализации общего плана по защите от взломов игры это был один из самых важных и в то же время масштабных пунктов. Важным, потому что без полного контроля над прогрессом игроков невозможно полноценно защититься от взломов. А масштабным, потому что более чем за пять лет жизни проекта в игре было огромное количество сущностей и функционала, каждый из которых хранил данные в собственном формате, сохранялся беспорядочно и не имел никаких ограничений по количеству обращений к диску на чтение и запись.
Для реализации этой задачи необходимо было переработать все функционалы, в которых что-либо сохранялось.
На этапе составления плана действий мы попытались найти кого-нибудь с подобным опытом, прошерстили интернет и пообщались с коллегами из других студий, но многие вообще не верили, что это возможно, учитывая количество накопившегося у нас легаси.
Что ж, для начала составили критерии, которым должна удовлетворять наша система хранения данных:
-
Пользовательский опыт, когда все действия в игре происходят моментально без задержек на серверную валидацию, не должен пострадать.
-
При запуске приложения одним пользователем на нескольких устройствах игрок должен видеть абсолютно одинаковые состояния.
-
Нельзя допустить, чтобы какие-либо проблемы с сервером приводили к запрету входа в игру.
-
Минимизировать трафик и нагрузку на сервер.
-
После реализации основного этапа поддержка и развитие новых функционалов должны быть максимально простыми и быстрыми.
-
Все должно быть надежным и исключать возможность несанкционированного накручивания прогресса.
-
Желательно оставить возможность пользоваться игрой оффлайн, так как такие режимы востребованы среди наших игроков.
Потом начали составлять план работ. Выделили несколько глобальных этапов:
-
Введение постоянного сокетного соединения клиент-сервер. Используемая до этого связь через https-запросы сильно ограничивала нас в реализации необходимых функционалов. Виделось, что при ней реализовать систему по всем требованиям не получится.
Тогда опыта использования постоянного сокетного соединения у нас еще не было (предполагалось, что в дальнейшем без соединения нельзя будет находиться в основных разделах игры), поэтому решили обкатать соединение постепенно.
Сначала сделали фоновое подключение и продублировали часть второстепенных функционалов на сокетное соединение с возможностью удаленного переключения, каким каналом связи клиенту пользоваться. Выкатили на пользователей, настроили сервера и убедились, что соединение работает стабильно.
Затем ввели полноценную работу с аккаунтами и добавили поддержку сокетного соединения у большего количества функционалов. До этого момента мы поддерживали оба канала связи. Когда убедились, что новая архитектура держит постоянную связь с сервером и справляется с нагрузками, то можно было выкатывать полноценную работу с прогрессом и все остальные переработанные функционалы.
-
Формат хранения данных определили JSON. Может он и не самый оптимизированный по трафику и работе, но удобен в использовании. Его мы расширяем и часто используем по проекту.
Глобально прогресс представляет собой Dictionary<int, object>, где каждая пара — это так называемые слоты для хранения данных. Каждый слот служит для хранения своего типа данных: слот валюты, слот инвентаря, слот ачивок и так далее. Ключ — он же номер слота данных — решили сделать интовым, чтобы сократить использование трафика, значение в каждом слоте может иметь свой формат, но это всегда JSON.
Чтобы все действия на клиенте отрабатывались моментально, прогресс хранится и на клиенте, и на сервере (команды по изменению прогресса пишутся и там, и там). Отрабатывают сначала на клиенте: визуально пользователь видит, что все окей, а в это время на сервер отправляются параметры для команды. Там они проходят необходимые проверки, и если все хорошо, то прогресс меняется аналогичным способом, как и в клиенте. По результату сравниваются итоговые хеши изменившихся слотов на сервере и клиенте, если не сходятся — рвется соединение с клиентом, клиент переавторизовывается и подтягивает последнее валидное состояние слотов.
Чтобы сократить трафик между клиентом и сервером, для сравнения слотов отправляется хеш слота, а не слот целиком. И только при несовпадении хешей пересылается состояние слота от сервера клиенту при авторизации.
На случай временной потери интернета (или если, например, приложение закроется до отправки команды на сервер), команды сохраняются локально и при следующем запуске перед началом сравнения слотов на сервере и клиенте сначала отправляются они и только потом хеши имеющихся слотов.
Чтобы прогресс всегда был в консистентном состоянии, добавили возможность отправки нескольких команд на изменение прогресса в виде одной и назвали это снапшотом. Если не проходит валидацию одна из команд в снапшоте, то все команды в нем фейлятся и не применяются. Где это необходимо использовать: при добавлении опыта повышается уровень игрока, а за это ему необходимо выдать награду. Если не сойдется слот уровня (например, в случае локального его изменения до команды), то и добавление опыта не запишется, не создав ситуацию, когда у игрока опыта больше, чем может быть на уровне.
-
Для сохранения возможности входа в Pixel Gun 3D в те моменты, когда необходимо по каким-либо причинам остановить сервер прогресса, реализовали для него аварийный режим. При его включении все команды отрабатывают только локально. А когда включается штатный режим, клиент присылает серверу текущие слоты. В эти моменты, конечно, есть возможность что-либо накрутить через какой-нибудь мод. Но аварийный режим мы включаем крайне редко, да и визуально на клиенте никак этого не понять, поэтому вероятность, что этим воспользуются — минимальна.
-
Оставили возможность входа в игру при отсутствии интернета. Игрокам доступны одиночные режимы без наград и ряд других локальных возможностей, которые не требуют изменений прогресса.
Шаг №3. Миграция прогресса
При переходе от хранения прогресса игроков в сторонних сервисах к хранению на собственных серверах, необходимо было мигрировать эти данные, относящиеся именно к состоянию профиля. То есть полноценно перенести прогресс у честных игроков и не допустить, чтобы после ввода новой схемы работы прогресса можно было взламывать на прошлых версиях и путем обновления переносить данные к нам на сервер.
Для этого мы добавили выдачу уникальных ключей всем, кто заходил в игру во время реализации новой схемы прогресса (примерно полгода). В дальнейшем, как только выпустили версию с обновленным прогрессом, мы отключили на сервере выдачу этих ключей, а принимали мигрированный прогресс только от тех, у кого был ключ и только один раз (чтобы нельзя было вернуться в прошлую версию и начислить прогресс тем, кто этого не сделал ранее).
Так мы мигрировали прогресс всем активным игрокам и решили проблему взлома через старые незащищенные версии.
Шаг №4. Система бана
Реализовали надежную систему бана, которую нельзя вырезать из клиента, так как у забаненного игрока блокируется весь серверный функционал (при том, что большая часть логики уже была перенесена на сервер).
Ранее, когда не было постоянного соединения с сервером, не было возможности надежно забанить клиент — информация о бане запрашивалась отдельным http-запросом с сервера, и в случае положительного ответа в клиенте выводился баннер, что аккаунт забанен. Хоть мы и старались это спрятать в коде, появлялись различные моды, где весь мешающий жизни функционал был нейтрализован.
Сейчас, когда реализована постоянная связь с сервером, большинство функционалов просто не работает без соединения. Для бана достаточно запретить авторизацию при открытии соединения с отдельной ошибкой (для показа баннера, что игрок забанен), чтобы сделать невозможным пользование приложением с заблокированным аккаунтом.
Для защиты онлайна на серверах фотона от забаненных мы также сделали проверку с плагина фотона на факт бана. Если там выясняется, что такой id забанен, то игрока выкидывает из комнаты.
Шаг №5. Подсчет хеша всех библиотек
Одним из традиционных способов взлома является модификация библиотек приложения напрямую. В случае с приложениями на Unity — это libil2cpp.so (при билде через IL2CPP).
Для обнаружения таких изменений может использоваться проверка на несовпадение контрольных сумм (хеша библиотек). Самым контролируемым способом будет вычисление текущего хеша на клиенте и отправка его на сервер (где он будет сравнен с эталонным).
Получить путь до наших библиотек можно так:
public string GetLibraryDirectory()
{
var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer");
if (unityPlayer == null)
throw new InvalidOperationException("unityPlayer == null");
var _currentActivity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity");
if (_currentActivity == null)
throw new InvalidOperationException("_currentActivity == null");
AndroidJavaObject packageManager = _currentActivity.Call<AndroidJavaObject>("getPackageManager");
if (packageManager == null)
throw new InvalidOperationException("packageManager == null");
string packageName = _currentActivity.Call<string>("getPackageName");
if (string.IsNullOrEmpty(packageName))
throw new InvalidOperationException("string.IsNullOrEmpty(packageName)");
const int GetMetaData = 128;
AndroidJavaObject packageInfo = packageManager.Call<AndroidJavaObject>("getPackageInfo", packageName, GetMetaData);
if (packageInfo == null)
throw new InvalidOperationException("packageInfo == null");
AndroidJavaObject applicationInfo = packageInfo.Get<AndroidJavaObject>("applicationInfo");
if (applicationInfo == null)
throw new InvalidOperationException("applicationInfo == null");
string nativeLibraryDir = applicationInfo.Get<string>("nativeLibraryDir");
if (string.IsNullOrEmpty(nativeLibraryDir))
throw new InvalidOperationException("string.IsNullOrEmpty(nativeLibraryDir)");
return nativeLibraryDir;
}
Для автоматизации процесса при сборке билдов можно использовать OnPostprocessBuild в Unity и производить расчет эталонного хеша. Обратите внимание на то, что при сборке с включением нескольких платформ (ARM, x86) необходимо вычислять хеш по каждой платформе.
Что дальше
В следующий раз поговорим про остальные решения, а именно: защиту от переподписывания версий, Photon Plugin, серверную валидацию инапов, защиту от взлома оперативной памяти, одновременный релиз всех решений и собственную аналитику. А про некоторые объемные пункты уже готовим отдельные, более подробные материалы.
Автор: Николай Черкашин