¿Cómo proteger los datos del juego en Unity en RAM?

imagen



¡Hola! No es ningún secreto que existen muchos programas para piratear juegos y aplicaciones. También hay muchas formas de piratear. Por ejemplo, descompilación y modificación del código fuente (con la posterior publicación de APK personalizados, por ejemplo, con oro infinito y todas las compras pagadas). O la forma más versátil es escanear, filtrar y editar los valores en RAM. Cómo lidiar con esto último, te lo diré debajo del corte.



En general, tenemos un perfil de jugador con un montón de parámetros, que se serializa en el Juego Guardado y se carga / guarda cuando el juego comienza / termina. Y si agregar cifrado durante la serialización es bastante simple, entonces proteger el mismo perfil en la RAM es algo más difícil. Intentaré dar un ejemplo simple:



var money = 100; // "100" is present in RAM now (as four-byte integer value). Cheat apps can find, filter and replace it since it was declared.

money += 20; // Cheat apps can scan RAM for "120" values, filter them and discover the RAM address of our "money" variable.

Debug.Log(money); // We expect to see "120" in console. But cheat apps can deceive us!

ProtectedInt experience = 500; // four XOR-encrypted bytes are present in RAM now. Cheat apps can't find our value in RAM.

experience += 100;

Debug.Log(experience); // We can see "600" in console;

Debug.Log(JsonUtility.ToJson(experience)); // We can see four XOR-encrypted bytes here: {"_":[96,96,102,53]}. Our "experience" is hidden.


El segundo punto al que vale la pena prestar atención es que la introducción de una nueva protección debería realizarse con cambios mínimos en el código fuente del juego, donde todo ya funciona bien y se ha probado muchas veces. En mi método, será suficiente reemplazar los tipos int / long / float con ProtectedInt / ProtectedLong / ProtectedFloat . A continuación, proporcionaré comentarios y código.



La clase base Protegida almacena una matriz cifrada de bytes en el campo "_", también es responsable de cifrar y descifrar datos. El cifrado es primitivo: XOR con clave . Este cifrado es rápido, por lo que puede trabajar con variables incluso en Update... La clase base trabaja con matrices de bytes. Las clases secundarias son responsables de convertir su tipo hacia y desde una matriz de bytes. Pero lo más importante es que están "disfrazados" como tipos simples utilizando el operador implícito , por lo que el desarrollador puede que ni siquiera note que el tipo de las variables ha cambiado. También puede observar los atributos de algunos de los métodos y propiedades que se necesitan para la serialización con JsonUtility y Newtonsoft.Json (ambos son compatibles al mismo tiempo). Si no está utilizando Newtonsoft.Json, debe eliminar el #define NEWTONSOFT_JSON .



#define NEWTONSOFT_JSON

using System;
using UnityEngine;

#if NEWTONSOFT_JSON
using Newtonsoft.Json;
#endif

namespace Assets
{
    [Serializable]
    public class ProtectedInt : Protected
    {
        #if NEWTONSOFT_JSON
        [JsonConstructor]
        #endif
        private ProtectedInt()
        {
        }

        protected ProtectedInt(byte[] bytes) : base(bytes)
        {
        }

        public static implicit operator ProtectedInt(int value)
        {
            return new ProtectedInt(BitConverter.GetBytes(value));
        }

        public static implicit operator int(ProtectedInt value) => value == null ? 0 : BitConverter.ToInt32(value.DecodedBytes, 0);

        public override string ToString()
        {
            return ((int) this).ToString();
        }
    }
    
    [Serializable]
    public class ProtectedFloat : Protected
    {
        #if NEWTONSOFT_JSON
        [JsonConstructor]
        #endif
        private ProtectedFloat()
        {
        }

        protected ProtectedFloat(byte[] bytes) : base(bytes)
        {
        }

        public static implicit operator ProtectedFloat(int value)
        {
            return new ProtectedFloat(BitConverter.GetBytes(value));
        }

        public static implicit operator float(ProtectedFloat value) => value == null ? 0 : BitConverter.ToSingle(value.DecodedBytes, 0);

        public override string ToString()
        {
            return ((float) this).ToString(System.Globalization.CultureInfo.InvariantCulture);
        }
    }

    public abstract class Protected
    {
        #if NEWTONSOFT_JSON
        [JsonProperty]
        #endif
        [SerializeField]
        private byte[] _;

        private static readonly byte[] Key = System.Text.Encoding.UTF8.GetBytes("8bf5b15ffef1f485f673ceb874fd6ef0");

        protected Protected()
        {
        }

        protected Protected(byte[] bytes)
        {
            _ = Encode(bytes);
        }

        private static byte[] Encode(byte[] bytes)
        {
            var encoded = new byte[bytes.Length];

            for (var i = 0; i < bytes.Length; i++)
            {
                encoded[i] = (byte) (bytes[i] ^ Key[i % Key.Length]);
            }

            return encoded;
        }

        protected byte[] DecodedBytes
        {
            get
            {
                var decoded = new byte[_.Length];

                for (var i = 0; i < decoded.Length; i++)
                {
                    decoded[i] = (byte) (_[i] ^ Key[i % Key.Length]);
                }

                return decoded;
            }
        }
    }
}


Si ha olvidado o cometido un error en algún lugar, escriba en los comentarios =) ¡Buena suerte con el desarrollo!



PD. El gato no es mío, el autor de la foto es CatCosplay.



UPD. En los comentarios se hicieron las siguientes observaciones sobre el caso:

  1. Es mejor pasar a la estructura para hacer que el código sea más predecible (aún más si nos disfrazamos como tipos de valores simples).
  2. La búsqueda en RAM se puede realizar no por valores específicos, sino por todas las variables cambiadas. XOR no ayudará aquí. Alternativamente, ingrese una suma de verificación.
  3. BitConverter es lento (a microescala, por supuesto). Es mejor deshacerse de él (para int resultó, para float, estoy esperando tus sugerencias).


A continuación se muestra una versión actualizada del código. ProtectedInt y ProtectedFloat ahora son estructuras. Me deshice de las matrices de bytes. Además introdujo la suma de comprobación _h como solución al segundo problema. Probé la serialización de ambas formas.



[Serializable]
public struct ProtectedInt
{
	#if NEWTONSOFT_JSON
	[JsonProperty]
	#endif
	[SerializeField]
	private int _;

	#if NEWTONSOFT_JSON
	[JsonProperty]
	#endif
	[SerializeField]
	private byte _h;

	private const int XorKey = 514229;

	private ProtectedInt(int value)
	{
		_ = value ^ XorKey;
		_h = GetHash(_);
	}

	public static implicit operator ProtectedInt(int value)
	{
		return new ProtectedInt(value);
	}

	public static implicit operator int(ProtectedInt value) => value._ == 0 && value._h == 0 || value._h != GetHash(value._) ? 0 : value._ ^ XorKey;

	public override string ToString()
	{
		return ((int) this).ToString();
	}

	private static byte GetHash(int value)
	{
		return (byte) (255 - value % 256);
	}
}

[Serializable]
public struct ProtectedFloat
{
	#if NEWTONSOFT_JSON
	[JsonProperty]
	#endif
	[SerializeField]
	private int _;

	#if NEWTONSOFT_JSON
	[JsonProperty]
	#endif
	[SerializeField]
	private byte _h;

	private const int XorKey = 514229;

	private ProtectedFloat(int value)
	{
		_ = value ^ XorKey;
		_h = GetHash(_);
	}

	public static implicit operator ProtectedFloat(float value)
	{
		return new ProtectedFloat(BitConverter.ToInt32(BitConverter.GetBytes(value), 0));
	}

	public static implicit operator float(ProtectedFloat value) => value._ == 0 && value._h == 0 || value._h != GetHash(value._) ? 0f : BitConverter.ToSingle(BitConverter.GetBytes(value._ ^ XorKey), 0);

	public override string ToString()
	{
		return ((float) this).ToString(CultureInfo.InvariantCulture);
	}

	private static byte GetHash(int value)
	{
		return (byte) (255 - value % 256);
	}
}



All Articles