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

Итак, эти шаги:

  • Обфускация.

  • Хранение данных.

  • Миграция прогресса.

  • Система бана.

  • Подсчет хеша всех библиотек.

  • Защита от переподписывания версий.

  • 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. Хранение данных

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

Для реализации этой задачи необходимо было переработать все функционалы, в которых что-либо сохранялось. 

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

Что ж, для начала составили критерии, которым должна удовлетворять наша система хранения данных:

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

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

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

  • Минимизировать трафик и нагрузку на сервер.

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

  • Все должно быть надежным и исключать возможность несанкционированного накручивания прогресса.

  • Желательно оставить возможность пользоваться игрой оффлайн, так как такие режимы востребованы среди наших игроков.

Потом начали составлять план работ. Выделили несколько глобальных этапов:

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

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

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

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

  2. Формат хранения данных определили JSON. Может он и не самый оптимизированный по трафику и работе, но удобен в использовании. Его мы расширяем и часто используем по проекту.

    Глобально прогресс представляет собой Dictionary<int, object>, где каждая пара — это так называемые слоты для хранения данных. Каждый слот служит для хранения своего типа данных: слот валюты, слот инвентаря, слот ачивок и так далее. Ключ — он же номер слота данных — решили сделать интовым, чтобы сократить использование трафика, значение в каждом слоте может иметь свой формат, но это всегда JSON.

    Чтобы все действия на клиенте отрабатывались моментально, прогресс хранится и на клиенте, и на сервере (команды по изменению прогресса пишутся и там, и там). Отрабатывают сначала на клиенте: визуально пользователь видит, что все окей, а в это время на сервер отправляются параметры для команды. Там они проходят необходимые проверки, и если все хорошо, то прогресс меняется аналогичным способом, как и в клиенте. По результату сравниваются итоговые хеши изменившихся слотов на сервере и клиенте, если не сходятся — рвется соединение с клиентом, клиент переавторизовывается и подтягивает последнее валидное состояние слотов.

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

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

    Чтобы прогресс всегда был в консистентном состоянии, добавили возможность отправки нескольких команд на изменение прогресса в виде одной и назвали это снапшотом. Если не проходит валидацию одна из команд в снапшоте, то все команды в нем фейлятся и не применяются. Где это необходимо использовать: при добавлении опыта повышается уровень игрока, а за это ему необходимо выдать награду. Если не сойдется слот уровня (например, в случае локального его изменения до команды), то и добавление опыта не запишется, не создав ситуацию, когда у игрока опыта больше, чем может быть на уровне.

  3. Для сохранения возможности входа в Pixel Gun 3D в те моменты, когда необходимо по каким-либо причинам остановить сервер прогресса, реализовали для него аварийный режим. При его включении все команды отрабатывают только локально. А когда включается штатный режим, клиент присылает серверу текущие слоты. В эти моменты, конечно, есть возможность что-либо накрутить через какой-нибудь мод. Но аварийный режим мы включаем крайне редко, да и визуально на клиенте никак этого не понять, поэтому вероятность, что этим воспользуются — минимальна.

  4. Оставили возможность входа в игру при отсутствии интернета. Игрокам доступны одиночные режимы без наград и ряд других локальных возможностей, которые не требуют изменений прогресса.

Шаг №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, серверную валидацию инапов, защиту от взлома оперативной памяти, одновременный релиз всех решений и собственную аналитику. А про некоторые объемные пункты уже готовим отдельные, более подробные материалы.