Разработчик
0,0
рейтинг
4 февраля 2015 в 13:44

Разработка → Защита игр и мобильных приложений от взлома для чайников (Unity, C#, Mono) tutorial

Всем снова здравствуйте! Дошли руки написать крутую статью на весьма важную тему для разработчиков игр. Итак, поговорим о защите ваших драгоценных игр и приложений, которые вы пилите на Unity в надежде заработать на буханку хлеба, от взлома злобными школьниками. Почему школьниками? Потому что надежной на 100% защиты априори быть не может. И кто захочет, все равно взломает. Вопрос лишь в том, сколько времени и сил он на это потратит. И как любят шутить безопасники — терморектальный криптоанализ никто не отменял.

Итак, в статье я постараюсь максимально доступно рассказать о 3 аспектах (и конечно, предложу реализацию):
  • защита данных приложения (сейвов)
  • защита памяти приложения
  • защита внутриигровых покупок (Google Play)

image

1. Подготовка


Для начала нужно научиться преобразовывать игровые данные (типы, классы) в строки. Стоит изучить JSON или XML сериализацию. Начинать с XML не советую, т.к. возникнут проблемы с iOS. Лучше изучить JSON, вот ссылка wiki.unity3d.com/index.php/SimpleJSON. К сожалению, это тема отдельной статьи и я не буду на этом останавливаться. Если лениво разбираться — можно по старинке лепить строку вручную с помощью сепараторов. Например:

var profile = "name=player;money=999;level=80";

Еще нужно уметь преобразовывать строки в массивы байт и обратно. Тут все просто:

var bytes = Encoding.Default.GetBytes(profile);

profile = Encoding.Default.GetString(bytes);

Далее строку можно завуалировать, применив к ней base64 преобразование. Особо отмечу, что base64 не является шифрованием, он не имеет ключа шифрования и все такое. base64 преобразует вашу строку в новую строку, состоящую только из ASCII символов. Наглядно посмотреть, как это происходит, можно по ссылке base64.ru. Я просто приведу код реализации:

using System;
using System.Text;

namespace Assets.Scripts.Common
{
	public static class Base64
    {
        public static string Encode(string plainText)
        {
            var plainTextBytes = Encoding.UTF8.GetBytes(plainText);

            return Convert.ToBase64String(plainTextBytes);
        }

        public static string Decode(string base64EncodedData)
        {
            var base64EncodedBytes = Convert.FromBase64String(base64EncodedData);

            return Encoding.UTF8.GetString(base64EncodedBytes);
        }
    }
}

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

2. Защита игровых данных (сейвов)


Итак, теперь мы умеем преобразовывать игровые данные в строку. Теперь надо подумать, куда их сохранять. Первым делом в голову приходит сохранять сейвы в файлы в Application.persistentDataPath. Минусов у данного способа два:
  • Application.persistentDataPath может измениться при обновлении приложения (например, приложение переместится на SD карту). Соответственно, файл сохранения будет not found, а пользователь потеряет весь прогресс
  • Это не будет работать в web-плеере и windows phone

Второй и самый правильный способ — сохранять в PlayerPrefs. Пример ниже:

const string key = "profile";
var profile = "name=player;money=999;level=80";

PlayerPrefs.SetString(key, profile);
PlayerPrefs.Save();

if (PlayerPrefs.HasKey(key))
{
    profile = PlayerPrefs.GetString(key);
}

О да, детка, супер! Теперь нужно зашифровать наши сохранения. Тут можно по-быстрому выполнить base64 преобразование, это уже защитит сохранения от редактирования через большинство программ для взлома. Но по хардкору самое время прикрутить нормальное шифрование. Сразу к делу, берем AES и шифруем. Копипастим файл AES.cs и не задаемся вопросом, как это работает:

using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;

namespace Assets.Scripts.Common
{
    /// <summary>
    /// AES (Advanced Encryption Standard) implementation with 128-bit key (default)
    /// - 128-bit AES is approved  by NIST, but not the 256-bit AES
    /// - 256-bit AES is slower than the 128-bit AES (by about 40%)
    /// - Use it for secure data protection
    /// - Do NOT use it for data protection in RAM (in most common scenarios)
    /// </summary>
    public static class AES
    {
        public static int KeyLength = 128;
        private const string SaltKey = "ShMG8hLyZ7k~Ge5@";
        private const string VIKey = "~6YUi0Sv5@|{aOZO"; // TODO: Generate random VI each encryption and store it with encrypted value

        public static string Encrypt(byte[] value, string password)
        {
            var keyBytes = new Rfc2898DeriveBytes(password, Encoding.UTF8.GetBytes(SaltKey)).GetBytes(KeyLength / 8);
            var symmetricKey = new RijndaelManaged { Mode = CipherMode.CBC, Padding = PaddingMode.Zeros };
            var encryptor = symmetricKey.CreateEncryptor(keyBytes, Encoding.UTF8.GetBytes(VIKey));

            using (var memoryStream = new MemoryStream())
            {
                using (var cryptoStream = new CryptoStream(memoryStream, encryptor, CryptoStreamMode.Write))
                {
                    cryptoStream.Write(value, 0, value.Length);
                    cryptoStream.FlushFinalBlock();
                    cryptoStream.Close();
                    memoryStream.Close();

                    return Convert.ToBase64String(memoryStream.ToArray());
                }
            }
        }

        public static string Encrypt(string value, string password)
        {
            return Encrypt(Encoding.UTF8.GetBytes(value), password);
        }

        public static string Decrypt(string value, string password)
        {
            var cipherTextBytes = Convert.FromBase64String(value);
            var keyBytes = new Rfc2898DeriveBytes(password, Encoding.UTF8.GetBytes(SaltKey)).GetBytes(KeyLength / 8);
            var symmetricKey = new RijndaelManaged { Mode = CipherMode.CBC, Padding = PaddingMode.None };
            var decryptor = symmetricKey.CreateDecryptor(keyBytes, Encoding.UTF8.GetBytes(VIKey));

            using (var memoryStream = new MemoryStream(cipherTextBytes))
            {
                using (var cryptoStream = new CryptoStream(memoryStream, decryptor, CryptoStreamMode.Read))
                {
                    var plainTextBytes = new byte[cipherTextBytes.Length];
                    var decryptedByteCount = cryptoStream.Read(plainTextBytes, 0, plainTextBytes.Length);

                    memoryStream.Close();
                    cryptoStream.Close();

                    return Encoding.UTF8.GetString(plainTextBytes, 0, decryptedByteCount).TrimEnd("\0".ToCharArray());
                }
            }
        }
    }
}

3. Защита памяти приложения


Все помнят такую PC программу, как ArtMoney? Она умела искать значения в RAM и за несколько итераций отсеивания позволяла стать миллионером в игре. Сейчас для Android и iOS подобных программ развелось очень много, например самая популярная — GameKiller.

Защититься от таких программ довольно просто — нужно шифровать значения в памяти приложения. Шифровать КАЖДЫЙ РАЗ при записи и дешифровать КАЖДЫЙ РАЗ при чтении. И так как операция довольно частая, нет смысла использовать тяжелый AES и нам нужен супербыстрый алгоритм. Я предлагаю несколько модифицировать наш base64 и реализовать свое шифрование — эффективное, быстрое, с блэкджеком и XOR:

using System;
using System.Text;

namespace Assets.Scripts.Common
{
    /// <summary>
    /// Simple and fast Base64 XOR encoding with dynamic key (generated on each app run). Use for data protection in RAM. Do NOT use for data storing outside RAM. Do NOT use for secure data encryption.
    /// </summary>
    public class B64X
    {
        public static byte[] Key = Guid.NewGuid().ToByteArray();

        public static string Encode(string value)
        {
            return Convert.ToBase64String(Encode(Encoding.UTF8.GetBytes(value), Key));
        }

        public static string Decode(string value)
        {
            return Encoding.UTF8.GetString(Encode(Convert.FromBase64String(value), Key));
        }

        public static string Encrypt(string value, string key)
        {
            return Convert.ToBase64String(Encode(Encoding.UTF8.GetBytes(value), Encoding.UTF8.GetBytes(key)));
        }

        public static string Decrypt(string value, string key)
        {
            return Encoding.UTF8.GetString(Encode(Convert.FromBase64String(value), Encoding.UTF8.GetBytes(key)));
        }

        private static byte[] Encode(byte[] bytes, byte[] key)
        {
            var j = 0;

            for (var i = 0; i < bytes.Length; i++)
            {
                bytes[i] ^= key[j];

                if (++j == key.Length)
                {
                    j = 0;
                }
            }

            return bytes;
        }
    }
}

Теперь, как только мы прочитали и расшифровали AES-ом профиль из сохранения, сразу шифруем все значения этим B64X (название я сам придумал). И расшифровываем каждый раз, когда нужно узнать, сколько денег у игрока, какой у него уровень и т.д. B64X может использовать ключ (пароль) для шифрования, а может использовать рандомный сессионный ключ, чтобы мы не парились, где и как его хранить.

4. Защита внутриигровых покупок


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

Опустим рассмотрение механизма проверки покупок на сервере разработчика, ведь не у всех он есть. Расскажу, что предлагает Google в таких случаях.

UPD: Unity реализовал механизм покупок и их проверку (http://docs.unity3d.com/Manual/UnityAnalyticsReceiptVerification.html), поэтому информация ниже теперь имеет только теоретическую нагрузку.

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

image

Вы его еще используете при реализаци игрового магазина в приложении.

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

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

Перейдем к реализации. Для начала нужно выполнить одну неприятную операцию. Нужно преобразовать открытый base64 ключ из консоли разработчика в xml-ключ, который подойдет для дешифрования подписи. Свиду кажется, что достаточно просто раскодировать его base64. Но это не так. Предлагаю воспользоваться онлайн сервисом и сразу прикопать xml-ключ в приложении. Особо заморачиваться о его защите не стоит — это же открытый ключ. Его могут сфабриковать, но это уже другая история. Итак, сервис вот, вставляем туда свой base64 ключ и получаем xml-ключ: superdry.apphb.com/tools/online-rsa-key-converter

image

В нижнем поле и есть наш xml-ключ. Сохраняем его в игре или приложении. А дальше все просто. Google возвращает нам покупку. Если использовать в приложении бесплатный плагин для реализации покупок OpenIAB, то это объект класса Purchase, у него есть 2 нужных нам поля:

Purchase purchase;

var json = purchase.OriginalJson;
var signature = purchase.Signature;

Теперь приведу реализацию механизма проверки подписи:

using System;
using System.Security.Cryptography;

namespace Assets.Scripts.Common
{
    public static class GooglePlayPurchaseGuard
    {
        /// <summary>
        /// Verify Google Play purchase. Protect you app against hack via Freedom. More info: http://mrtn.me/blog/2012/11/15/checking-google-play-signatures-on-net/
        /// </summary>
        /// <param name="purchaseJson">Purchase JSON string</param>
        /// <param name="base64Signature">Purchase signature string</param>
        /// <param name="xmlPublicKey">XML public key. Use http://superdry.apphb.com/tools/online-rsa-key-converter to convert RSA public key from Developer Console</param>
        /// <returns></returns>
        public static bool Verify(string purchaseJson, string base64Signature, string xmlPublicKey)
        {
            using (var provider = new RSACryptoServiceProvider())
            {
                try
                {
                    provider.FromXmlString(xmlPublicKey);

                    var signature = Convert.FromBase64String(base64Signature);
                    var sha = new SHA1Managed();
                    var data = System.Text.Encoding.UTF8.GetBytes(purchaseJson);

                    return provider.VerifyData(data, sha, signature);
                }
                catch (Exception e)
                {
                    UnityEngine.Debug.Log(e);
                }

                return false;
            }
        }
    }
}


Ну и теперь, когда от Google пришел ответ, что покупка совершена, проверяем ее подпись и показываем игроку фигу, если подпись не совпадает:

if (GooglePlayPurchaseGuard.Verify(purchase.OriginalJson, purchase.Signature, publicKeyXml))
{
}
else
{
}

Хочу отметить, что при совершении покупки лучше добавить рандомный payload к запросу, это защитит от атак man-in-the-middle, когда вам могут повторно подпихивать корректный ответ сервера с правильной, но одной и той же цифровой подписью. Это необязательный аргумент в реализации OpenIAB, на который большинство кладут болт:

public static void purchaseProduct(string sku, string developerPayload = "")

Более подробное описание механизма можно найти на английском по ссылке: mrtn.me/blog/2012/11/15/checking-google-play-signatures-on-net

5. Заключение


Надеюсь, статья была не слишком занудная. В любом случае спасибо за внимание, делайте качественные игры и дарите игрокам новые впечатления!
P.S. В последнее время тянет в игровую индустрию и хочется сменить сферу деятельности)
Олег @natexriver
карма
28,0
рейтинг 0,0
Разработчик
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Спецпроект

Самое читаемое Разработка

Комментарии (30)

  • 0
    Спасёт ли это от Locallappstore?
    • 0
      К сожалению, еще не изучал механизм покупок в AppStore. Поэтому написал только про Google Play.
    • 0
      Нет, на сколько я знаю все актуальные программы для подделки IAP на iOS умеют так же подделывать сервер верификации. Для защиты от подобных программ надо передавать и верифицировать покупки на сервере. При чем некоторые программы для взлома могут отдавать валидные ключи верификации, просто, например, от другого приложения. Поэтому сервер должен полностью проверять ответ от эпла.
  • 0
    Не VIKey, а IVKey (IV — initialization vector)
    • 0
      Очепятка, поправил, спасибо!
      • 0
        З.Ы. Да и не ключ это таки совсем, а вектор инициализации. Если придираться к терминологии.
        По сути — таже соль только для алгоритма шифрования.
      • +1
        IV оставлять константой не правильно. Он нужен для того что бы одинаковые данные зашифрованные тем же ключем выглядели по разному. А у вас тут уязвимость.
        • 0
          Согласен. По гадлайнам нужно каждый раз при шифровании генерировать случайный IV и хранить его вместе с зашифрованным сообщением. Допишу в TODO.
        • 0
          Вообще говоря нет, это если Mode == CipherMode.ECB (самый небезопасный), то одинаковые последовательные блоки шифровались бы одинаковыми блоками. А при других значениях будет по-другому.
  • 0
    Очень интересует, почему в юнити( даже скрипты из коробки) предпочитают писать под js? Чем и насколько это преимущественнее нежели c#?

    Отдельно хотелось бы узнать про работу с json массивами в unity c#. С js и php мне доводилось работать-там это довольно просто(foreach выручает), а в c# у меня очень много проблем с «отловом» типа и перебором десериализованных данных.
    • 0
      Среди моих знакомых Unity-программистов большинство пишет на C#. Насчет JSON — если использовать реализацию SimpleJSON, то все так же просто, как и в php, foreach тоже будет работать. Дело привычки.
      • 0
        Я сам проходил курс js на codeacademy, но писать начал на c#. Как-то легко пошло, не знаю. Но едва ли не весь материал, который я гуглю, что на стэковерфлоу, что на юрити ансез написан именно на js.

        Simplejson я и использовал, но десериализлванный var(тип данных я так и не выявил) в цикле foreach ругался на то, что это не массив list или что-то вроде.
        • 0
          Там есть геттер AsArray
  • 0
    private const string VIKey = "~6YUi0Sv5@|{aOZO"

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

    new RijndaelManaged { Mode = CipherMode.CBC, Padding = PaddingMode.Zeros }

    Не стоит CBC. Из «коробочных» лучше взять CFB или OFB, а то здесь padding oracle на ровном месте получится (даже, если CryptographicException из cryptoStream.Read() будет ловиться где-то выше, то по-прежнему будет возможность получить time-based oracle). А то, при такой реализации, получается, что есть возможность полностью расшифровать значение всего сейва без ключа и зашифровать в него произвольный текст, за исключением его первого блока.
    • 0
      VIKey хранится в приложении. Его, конечно, можно дополнительно закрыть. Но считаем, что если взломщик дошел до анализа и модификации исполняемого кода, то тут уже ничто не поможет. Халявные плюшки ему за труды)
    • –1
      В векторе инициализации мало смысла, если он хранится в открытом виде и может быть извлечен атакующим.

      Бред.
      Вы бы почитали сперва зачем этот вектор и как влияет на шифротекст. Можете здесь.
      • –2
        В режиме CBC, при атаке на оракул дополнения, вектор инициализации влияет на возможность атакующего расшифровать первый (и только первый) блок сообщения. Если атакующему известен IV, то он может расшифровать с помощью оракула все блоки сообщения. Если нет, то все, за исключением первого. Можете почитать об этом прямо здесь. А можете взять сплоит github.com/kochetkov/Yapoet и убедиться в этом сами. Тестовое уязвимое приложение, на котором можно поэкспериментировать, там есть.
        • –1
          Я всё это прекрасно знаю.
          Тут проблема в выборе режима алгоритма для конкретной задачи а не в том что IV открыт.
          Не нужно людей вводить в заблуждение.
          • –2
            Проблема уязвимости к атаке — в выбранном режиме. Проблема подверженности ей в полной мере — в открытости IV. Никаких заблуждений.

            Что же касается вопроса открытости IV безотносительно конкретной атаки, то он должен быть либо случайным и непредсказуемым и тогда может быть открытым (как и упомянуто по вашей ссылке), вплоть до передачи его в качестве первого блока сообщения. В любом другом случае, его стоит скрыть (что является security by obscurity, плохо — да, но все же имеет хоть какой-то смысл, по сравнению с открытым константным IV).
  • 0
    Спасибо за статью! Часть про проверку подписи маркета многим пригодится.
    Внутри-игровые покупки вообще ставят разработчика в неудобное положение — с одной стороны, все, что связано с деньгами, однозначно нужно проверять на сервере. С другой — что, если это однопользовательская игра и сервера просто нет?
    Пока все выгладит так, будто любая игра, которая хранит данные о купленом контенте на клиенте, может оказаться где-нибудь на 4pda, в виде apk файла с фичей «энергия бесконечна, все премиумные шмотки уже куплены». Есть подозрение, что тут нужно уже не просто «прятать» переменные, но закладывать дополнительные проверки в игровой механике.
    Если бы кто-то поделился опытом разруливания такой ситуации — было бы очень здорово. Очень интересный момент.
    • 0
      Нет сервера, проверяйте на клиенте, там всего-то надо пару запросов отправить.
  • 0
    Спасибо большое. Как раз пришло время реализовывать защиту данных в игре.
  • +3
    А почему бы не пользоваться SecureString, вместо своих велосипедов по шифрованию памяти?
  • 0
    XML не советую, т.к. возникнут проблемы с iOS

    ?
    • 0
      Вкратце — в iOS нет Reflection, т.к. .NET для iOS основана на технологии AOT. При реализации приходится самому переопределять и реализовывать методы для составления и разбора словаря.
      • 0
        Вкратце, это не так, потому что AOT без метаданных не работает, сборки всегда включены в готовое приложение и Reflection на iOS функционирует в той или иной мере (а может и на 100%, не проверял полностью).

        Если детально, то утверждение «не пользуйтесь XML, у него проблемы в iOS» не корректно, пока жив и работает System.Xml (а появился он 13 лет назад, работает буквально везде на Mono/.Net и отмирать начал лишь сейчас на WinRT/WP в сторону System.Xml.Linq). Вопрос удобства и разбора уже тема отдельная, но всяко лучше строки с сепараторами :)

        P.S. По существу статьи как раз комментариев нет и холивар тут разводить не о чем. Просто утверждение уж очень категоричное было.
        • 0
          Я имел ввиду, что начинающий пользователь обязательно наткнется на проблемы при работе с XML. Об ограничениях на iOS подробно написано тут: developer.xamarin.com/guides/ios/advanced_topics/limitations/
          • 0
            Все-равно не понимаю. На какие проблемы он натолкнется? По ссылке ничего про XML нет, по опыту скажу, что никаких проблем с XML я за последние годы не замечал. Вот с SQLite есть проблемы, но это отдельный раговор.
            Если же начинающий пользователь не умеет вообще работать с XML, но отлично оперирует с JSON и AES шифрованием, то я вообще отстал от жизни :)
  • +1
    Все помнят такую PC программу, как ArtMoney? Она умела искать значения в RAM и за несколько итераций отсеивания позволяла стать миллионером в игре.


    • Если игра сетевая, то главная защита должна быть на сервере, а на клиенте нужно защищать протокол общения с сервером. Деньги у игрока появляются после подтверждения неких действий сервером, а не игрок серверу указывает сколько у него денег.
    • Если игра локальная и юзер облегчит себе игровой процесс, то это его сознательный выбор. Может он хочет таким образом побыстрее выиграть все бонусы и избавиться от игровой зависимости к данной игре ;)
    • Если Вы хотите добиться рабства от игрока, тогда соглашусь… шифруйте все и вся. Заодно юзер будет вынужден прикупить аппарат попроизводительнее.

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