Защита игр и мобильных приложений от взлома для чайников (Unity, C#, Mono)
Всем снова здравствуйте! Дошли руки написать крутую статью на весьма важную тему для разработчиков игр. Итак, поговорим о защите ваших драгоценных игр и приложений, которые вы пилите на Unity в надежде заработать на буханку хлеба, от взлома злобными школьниками. Почему школьниками? Потому что надежной на 100% защиты априори быть не может. И кто захочет, все равно взломает. Вопрос лишь в том, сколько времени и сил он на это потратит. И как любят шутить безопасники — терморектальный криптоанализ никто не отменял.Итак, в статье я постараюсь максимально доступно рассказать о 3 аспектах (и конечно, предложу реализацию):
защита данных приложения (сейвов) защита памяти приложения защита внутриигровых покупок (Google Play) 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
{
///
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
{
///
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 — открытый и закрытый ключ. Если не знаете, что это такое — погуглите асиметричное шифрование. Открытый ключ можно получить в консоли разработчика:
Вы его еще используете при реализаци игрового магазина в приложении.
Закрытый ключ Google вам никогда не покажет и будет использовать его для цифровой подписи покупок. Соответственно закрытым ключом можно только зашифровать подпись, а открытым ключом можно только расшифровать подпись.
Механизм защиты получается довольно простой — Google подписывает все json-ответы сервера покупок, и никто другой такую подпись подделать не может. Разработчик, зная открытый ключ, может проверить цифровую подпись ответов сервера. И если сервер был сфабрикован с помощью Freedom, то цифровая подпись будет неправильная.
Перейдем к реализации. Для начала нужно выполнить одну неприятную операцию. Нужно преобразовать открытый base64 ключ из консоли разработчика в xml-ключ, который подойдет для дешифрования подписи. Свиду кажется, что достаточно просто раскодировать его base64. Но это не так. Предлагаю воспользоваться онлайн сервисом и сразу прикопать xml-ключ в приложении. Особо заморачиваться о его защите не стоит — это же открытый ключ. Его могут сфабриковать, но это уже другая история. Итак, сервис вот, вставляем туда свой base64 ключ и получаем xml-ключ: superdry.apphb.com/tools/online-rsa-key-converter
В нижнем поле и есть наш 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
{
///
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. В последнее время тянет в игровую индустрию и хочется сменить сферу деятельности)