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

в 10:44, , рубрики: android, C#, iOS, unity, unity3d, безопасность, взлом, игры, информационная безопасность, приложения

Всем снова здравствуйте! Дошли руки написать крутую статью на весьма важную тему для разработчиков игр. Итак, поговорим о защите ваших драгоценных игр и приложений, которые вы пилите на 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";

        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("".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 в таких случаях.

При создании приложения в консоли разработчика 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

Источник

Поделиться

* - обязательные к заполнению поля