company_banner

Еще пять инструментов против читеров на мобильном проекте с DAU 1 млн пользователей

    Когда-то нам пришлось полностью переработать защиту популярного 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 сундуков, в следующем — игроки с тысячей гач и так далее. Затем вариантов несколько.

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

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

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

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

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

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

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

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

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

    Lightmap
    Разработчик мобильных игр

    Комментарии 10

      +2

      Ещё эффективно себя показывает система репортов.
      При этом она должна быть максимально простой. Чтоб жалобу можно было отправить буквально одним кликом.


      У вас есть что-то такое? Планируете добавить?

        +4
        Да, система репортов есть. Прямо в игре можно в таблице нажать на игрока и в открывшемся интерфейсе (где автоматически заполняются необходимые поля, например, ID пользователя) написать жалобу.

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

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

            +1
            Так и есть. Раньше активно пользовались, сейчас меньше, так как при большом DAU их очень сложно качественно обработать. Чтобы выявлять потенциальных читеров больше пользуемся аналитикой: автоматическими выгрузками с нее или приоритетно жалобами от модераторов.
        0
        Меня так банили в некоторых играх за высокие показатели(онлайн, ресы, аналог сундуков того времени). Но я не читерил, не взламывал, ботов тоже не использовал)

        Решение 9, ч. 2 не работает. Поиск неизвестного значения идёт по изменению. Массив не меняется, т.е. при поиске изменения отсеется полностью.

        А чем вам готовая сторонняя аналитика не угодила? Там ведь тоже можно все действия пользователя записать. Хотя, потом я вспомнил, что такая аналитика урезалась при большом кол-ве данных. А сколько по времени хранит данные база аналитики и сколько она весит?
          +2
          Решение 9, ч. 2 не работает. Поиск неизвестного значения идёт по изменению. Массив не меняется, т.е. при поиске изменения отсеется полностью.
          Здесь речь о защите неизменяемых значений в течение запуска приложения. Например, цена (чисто для примера, значение, конечно же, засолено) — там невозможно пользователю своими действиями изменить и отсеять. Тогда берут и меняют все значения, в такие выборки и этот массив попадает.
          А чем вам готовая сторонняя аналитика не угодила?
          Основная проблема: там агрегируются данные, и мы не можем сопоставить ивенты с конкретными нашими пользователями.
          А сколько по времени хранит данные база аналитики и сколько она весит?
          Без ограничения срока. Сейчас хранится около 800 ГБ данных примерно за 2 года.
          0
          Поэтому можно определить модифицированную игру, если хеш подписи не совпадает с нашим.
          Если я изменяю APK, то что мне мешает так же изменить функцию проверки подписи, так чтоб она всегда возвращала правильное значение?
            0
            Ничего не мешает, но надо эту функцию найти и определить правильное значение, а это уже отсеет значимое количество потенциальных модеров.
            0
            Здравствуйте, начал играть в эту игру более 5 лет назад. Активно играю два. У вас сейчас есть только один известный мне модер, он уже был передан в работу. К чему это я… Каждое обновление находится умник, который вытягивает новое оружие за несколько дней до релиза. У меня следующий вопрос: в чем проблема поставить бан тригер на эти оружия до релиза? «Если „Player“ владеет еще не вышедшим „АК-47“ до дня начала события, то он моментально отлетает в бан». Наверняка похожее предложение у вас уже звучало.
              +1
              Привет! Мы внимательно следим за этими игроками — такое получение пушек используется нами как один из триггеров для детального изучения взломов. С них собирается и изучается дополнительная информация, которую мы используем для выявления технологий взломов. А аккаунты затем отправляются в бан.

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

            Самое читаемое