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

Про «первые» пять решений — обфускацию, хранение данных, миграцию прогресса и систему бана — шла речь в этой статье. Сегодня расскажу про остальные, а именно: 

  • Защита от измененных версий.

  • Photon Plugin.

  • Серверная валидация инаппов.

  • Защита от взлома оперативной памяти.

  • Собственная аналитика.

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

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

Решение №6. Защита от измененных версий

В дополнительные места мы расставили защиту от переподписывания версий, лаунчеров (на Android) и твиков (на iOS), спрятав уже в обфусцированном коде.

Проверка на твики (iOS)

На устройствах с Jailbreak с помощью Cydia пользователи могут устанавливать твики, которые способны внедрять свой код в системные и установленные приложения. Каждый твик имеет информацию (файл *.plist), с какими бандлами они должны работать.

Механизм детекта осуществляется проверкой этих файлов в папке /Library/MobileSubstrate/DynamicLibraries/ (на наличие внутри нашего бандла).

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

string finalPath = string.Empty;
string substratePath = "/Library/MobileSubstrate/DynamicLibraries/";

bool bySymlink = false;

if (!Directory.Exists(substratePath)) //Если папки не существует (скрыт твиком xCon), то пытаемся получить доступ к файлам через созданный нами симлинк
{
	string symlinkPath = CreateSymlimk(substratePath);

	if (!string.IsNullOrEmpty(symlinkPath))
	{
		bySymlink = true;
		finalPath = symlinkPath;
	}
}
else
{
	finalPath = substratePath;
}


bool detected = false;
string detectedFile = string.Empty;

try
{
	if (!string.IsNullOrEmpty(finalPath))
	{
		string[] plistFiles = Directory.GetFiles(finalPath, "*.plist"));

		foreach (var plistFile in plistFiles)
		{
			if (File.Exists(plistFile))
			{
				StreamReader file = File.OpenText(plistFile);
				string con = file.ReadToEnd();

				string bundle = "app_bundle"; 

				if (con.Contains(bundle))
				{
					detectedFile = plistFile;
					detected = true;
					break;
				}
			}
		}
	}
}
catch (Exception ex)
{
	Debug.LogError(ex.ToString());
}

Но также есть твики, которые запрещают создание симлинков по проверяемому нами пути (KernBypass, A-Bypass). При их наличии мы не можем осуществить проверку, поэтому считаем это за возможное читерство.

Общего механизма детекта таких твиков нет, тут нужен индивидуальный подход.

Детект KernBypass (который был активен в отношении нашего бандла):

if (File.Exists("/var/mobile/Library/Preferences/jp.akusio.kernbypass.plist") 
{
	StreamReader file = File.OpenText("/var/mobile/Library/Preferences/jp.akusio.kernbypass.plist"); 
	string con = file.ReadToEnd();

	if (con.Contains("app_bundle") 
	{
		//detected
	}
}

Определение запуска через лаунчер (Android)

Запуск приложения через лаунчер — это, по сути, запуск вашего приложения внутри другого приложения (по типу Parallel Space). Некоторые реализации взломов используют такой механизм для внедрения своего кода, и для этого на устройстве не требуется root-доступ. Обычно они имитируют всю среду: выделяют папку под файлы приложения, возвращают фейковый Application Info и так далее.

При таком запуске у нас все равно сохраняется доступ ко всем файлам, к которым имеет доступ сам лаунчер. Самый простой способ детекта — это проверить доступ к материнской папке от нашего приложения (dataDir в applicationInfo) через функцию access (в нативном коде). В обычном случае операционная система не предоставит доступ, а в случае лаунчера это будет папка, которая все еще находится внутри Persistent Data приложения.

Код для плагина на C:

JavaVM*		java_vm;

jint JNI_OnLoad(JavaVM* vm, void* reserved) {
    
    java_vm = vm;
    return JNI_VERSION_1_6;
}

int CheckParentDirectoryAccess()
{
    JNIEnv* jni_env = 0;
    (*java_vm)->AttachCurrentThread(java_vm, &jni_env, NULL);

    jclass uClass = (*jni_env)->FindClass(jni_env, "com/unity3d/player/UnityPlayer");
    jfieldID activityID = (*jni_env)->GetStaticFieldID(jni_env, uClass, "currentActivity", "Landroid/app/Activity;");
    jobject obj_activity = (*jni_env)->GetStaticObjectField(jni_env, uClass, activityID);
    jclass classActivity = (*jni_env)->FindClass(jni_env, "android/app/Activity");
    
    jmethodID mID_func = (*jni_env)->GetMethodID(jni_env, classActivity,
                                                      "getPackageManager", "()Landroid/content/pm/PackageManager;");
    
    jobject pm = (*jni_env)->CallObjectMethod(jni_env, obj_activity, mID_func);
    
    jmethodID pmmID = (*jni_env)->GetMethodID(jni_env, classActivity,
                                                      "getPackageName", "()Ljava/lang/String;");
    
    jstring pName = (*jni_env)->CallObjectMethod(jni_env, obj_activity, pmmID);
    jclass pm_class = (*jni_env)->GetObjectClass(jni_env, pm);

    jmethodID mID_ai = (*jni_env)->GetMethodID(jni_env, pm_class, "getApplicationInfo","(Ljava/lang/String;I)Landroid/content/pm/ApplicationInfo;");

    jobject ai = (*jni_env)->CallObjectMethod(jni_env, pm, mID_ai, pName, 128);
    jclass ai_class = (*jni_env)->GetObjectClass(jni_env, ai);
    
    jfieldID nfieldID = (*jni_env)->GetFieldID(jni_env, ai_class,"dataDir","Ljava/lang/String;");
    jstring nDir = (*jni_env)->GetObjectField(jni_env, ai, nfieldID);
    
    const char *nDirStr = (*jni_env)->GetStringUTFChars(jni_env, nDir, 0);

    char parentDir[200];
    snprintf(parentDir, sizeof(parentDir), "%s/..", nDirStr);

    if (access(parentDir, W_OK) != 0)
    {
         return 1;
    }
	else
	{
		 return 0;
	}
}

Защита от переподписи apk (Android)

При любом вмешательстве внутрь apk-файла пакет необходимо переподписать, иначе система не позволит его установить. Поэтому можно определить модифицированную игру, если хеш подписи не совпадает с нашим. 

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

Получение хеша подписи в С# через обращение в Java-код:

Lazy<byte[]> defaultResult = new Lazy<byte[]>(() => new byte[20]);

            if (Application.platform != RuntimePlatform.Android)
                return defaultResult.Value;

#if UNITY_ANDROID
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");


            var packageManager = _currentActivity.Call<AndroidJavaObject>("getPackageManager");
            if (packageManager == null)
                throw new InvalidOperationException("getPackageManager() == null");

            // http://developer.android.com/reference/android/content/pm/PackageManager.html#GET_SIGNATURES
            const int getSignaturesFlag = 64;
            var packageInfo = packageManager.Call<AndroidJavaObject>("getPackageInfo", PackageName, getSignaturesFlag);
            if (packageInfo == null)
                throw new InvalidOperationException("getPackageInfo() == null");

            var signatures = packageInfo.Get<AndroidJavaObject[]>("signatures");
            if (signatures == null)
                throw new InvalidOperationException("signatures() == null");

            using (var sha1 = new SHA1Managed())
            {
                var hashes = signatures.Select(s => s.Call<byte[]>("toByteArray"))
                    .Where(s => s != null)
                    .Select<byte[], byte[]>(sha1.ComputeHash);

                var result = hashes.FirstOrDefault() ?? defaultResult.Value;
                return result;
            }
#else
            return defaultResult.Value;
#endif

Решение №7. Photon Plugin

Для организации сетевого взаимодействия между пользователями в игровых комнатах мы используем Photon Cloud, который сам по себе предполагает отсутствие серверной логики и отвечает только за пересылку пакетов между пользователями. А для защиты кора нам нужна была именно серверная логика.

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

Photon Plugin доступен на тарифе Enterprise Cloud и пишется на С#. Он запускается на серверах Photon и позволяет мониторить пересылаемый между пользователями игровой трафик, добавлять серверную логику, которая может: 

  • блокировать или добавлять сетевые сообщения; 

  • контролировать изменения свойств комнат и игроков; 

  • кикать из комнаты;

  • взаимодействовать при помощи http-запросов со сторонними серверами.

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

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

Решение №8. Серверная валидация инаппов

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

Валидация на сервере состоит из двух этапов:

  1. Превалидация. Когда данные по платежу отправляются на сервер соответствующей платформы для проверки валидности.

  2. Начисление. В случае успешно пройденной валидации купленных позиций.

Сначала сервер получает в качестве входных параметров данные, необходимые для проведения валидации (например, на Android — это id инаппа и токен). 

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

Чтобы логически объединить несколько команд, привязанных к одному действию игрока, на клиенте и на сервере мы ввели понятие снапшота. Это специальная конструкция из последовательности команд, которые одновременно выполняются или не выполняются на сервере.

Команда валидации проверяет транзакцию — в случае, если есть данные превалидации, то используются они. В противном случае данные отправляются на сервер валидации для соответствующей платформы.

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

Кроме того, в завершение начисления отправляется соответствующая статистика на сервер аналитики.

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

Решение №9. Защита от взлома оперативной памяти 

Локальные данные, которые не защищены валидацией с сервера, можно взломать в памяти (например, с помощью GameGuardian на Android). Механизм взлома заключается в поиске значений в памяти путем отсеивания. Ищется текущее значение, затем оно изменяется в игре, а среди найденных адресов в памяти они отсеиваются по новому значению, пока не будет найден нужный адрес.

Для их защиты они «засаливаются» при помощи случайно сгенерированной соли:

 internal int Value
{
	get { return _salt ^ _saltedValue; }
	set { _saltedValue = _salt ^ value; }
}

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

private static int[] refNumbers;

internal static void Start()
{
	refNumbers = new int[1000];

	for (int i = 0; i < refNumbers.Length; i++) 
	{
		refNumbers[i] = i;
	}
}

internal static bool Check()
{
	for (int i = 0; i < 1000; i++) 
	{
		if (!refNumbers [i].Equals(i))
			return true;
	}
}

Решение №10. Собственная аналитика

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

Но с ростом экспертизы внутри команды стала очевидной необходимость писать собственное решение с нужными именно нам фичами. Основная цель была в повышении продуктовых метрик и повороте компании в сторону Data-Driven подхода. Но в итоге аналитика также стала незаменимым инструментом в борьбе с читерами.Например, раньше система не привязывалась к пользователю, а просто считала ивенты. То есть мы знали, что 500 человек совершили какие-то действия, но кто эти 500 человек, и что они делали до этого — нет. Сейчас можно посмотреть все действия каждого конкретного игрока и, соответственно, отследить подозрительные операции.

Все пользовательские ивенты отправляются в одну большую SQL-евскую базу. Там есть как элементарные ивенты (игрок залогинился, сколько раз в день он залогинился и так далее), так и другие. Например, прилетает ивент, что игрок покупает оружие за столько-то монет, а вместо суммы написано 0. Очевидно, что он сделал что-то неправомерное.Большинство выгрузки с подозрительными действиями нарабатываются с опытом. Например, у нас есть скрипт, который показывает, что столько-то людей с конкретными id получили определенное большое количество монет. Но это не всегда читеры — обязательно нужно проверять.

Также читеров опознаем по несоответствию значений начисления валют. Аналитик знает, что за покупку инаппа начисляется конкретное количество гемов. У читеров часто это количество бывает 9999 — значит, что-то взломали в памяти. Еще бывают игроки с аномальными киллрейтами. По ним у нас тоже есть специально обученное поле, и когда появляется пользователь, у которого киллрейт 15 или 30, становится понятно, что, скорее всего, это читер.В основном отслеживанием занимается один скрипт, который пачкой прогоняет по детектам и сгружает все в таблицу. Аналитики получают id и видят игроков, которые залогинились утром с огромным количеством голды, в соседнем листе лежат игроки, открывшие 1000 сундуков, в следующем — игроки с тысячей гач и так далее. Затем вариантов несколько.

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

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

Одновременный релиз всех решений

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

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

Всего на глобальный ввод большинства защит ушло около семи месяцев. 

Самым масштабным пунктом была реализация системы хранения на наших серверах — именно она определяла запуск в продакшен всех решений из списка. Кроме аналитики, которая развивалась самостоятельно.

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

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