How to prevent external changes to your save files in Unity

Posted by : on

Category : C#   Unity

Having a save file in json format is useful because anyone can read its contents with a simple editor. Although we may want our save files to be readable by anyone, there are some use cases that we want to prevent changes to those files from an external source.

For example let’s say that we have the following class that is serializable and is responsible for storing the Name, Level and Gold of a character in our game, in a save file:

[Serializable]
public class SaveData
{
   public string Name;
   public int Level;
   public float Gold;
}

Although it is convenient to be able to save this class in json format so anyone can see its contents from any editor without having to start our game, we probably don’t want to allow the editing of that json file, as this could easily change the balance of our gameplay.

Let’s see how we can create a system that when a save file is loaded, we can detect if this file has been tampered.

We will create a hash that is being stored with our save file and is being compared every time we load that file.

In summary we will do the following:

For the save:

  • Wrap our class in a new class that holds our data plus a string with a password that is hardcoded in our program
  • serialize the instance of that new class in json
  • Create a hash of that json string
  • Change the password string, with a string that is the hash we calculated
  • Store the new class which is our data plus our hash in a file in json format

For the Load:

  • Load our save file
  • Deserialize it
  • Store the hash in a temporary string variable
  • Change the field in an instance of our class that holds our hash with our hardcoded password
  • Serialize in json that instance
  • Create the hash of this json string
  • Compare the hash we created with the hash in our temporary string variable
  • If the hash we calculated is equal to the stored hash, we unwrap the class to the original class that has our game data

If the stored hash in the file and the hash we calculated in our load method are the same, that means our file has not been tampered and holds the original contents from when it was created by our save system.

Calculating the Hash

Let’s start by creating a method that calculates a hash and returns it as a string:

string GetSHA256(string text)
{
    var textToBytes = Encoding.UTF8.GetBytes(text);
    var mySHA = SHA256.Create();
    
    var hashValue = mySHA.ComputeHash(textToBytes);

    return GetHexStringFromHash(hashValue);
        
    string GetHexStringFromHash(byte[] hash) => BitConverter.ToString(hash).Replace("-", "");
}

The GetSHA256 method takes as an argument a string, in our case the json string that we will create by our data and then transforms this string to a byte array.

We use the Create() method of the SHA256 class that exists in the System.Security.Cryptography namespace which is a factory that returns an instance of the SHA256 hash algorithm.

After that we use the ComputeHash method to get an array of bytes which is our hash value.

Because we want a string, the GetHexStringFromHash local method converts our hashValue to a string and then removes the dashes from it.

Now that we have a way to calculate a hash from a string let’s see how we can create a Save method. First we create a new serializable class, that will hold our data plus a new string, let’s call it LockedData:

Creating a wrapper class

[Serializable]
public class LockedData
{
   public SaveData Data;
   public string Hash;
}

Then we create our save method:

Creating our Save method

void SaveFile(SaveData data)
{

    LockedData lockedData = CreateLockedData(data);
    
    var json = JsonUtility.ToJson(lockedData);

    lockedData.Hash = GetSHA256(json);

    json = JsonUtility.ToJson(lockedData);
        
    var fileStream = new FileStream(SaveFilename(), FileMode.Create);
    using (var writer = new StreamWriter(fileStream))
    {
        writer.Write(json);
    }
        
    LockedData CreateLockedData(SaveData saveData) => new() {Data = saveData, Hash = PASSWORD};
}

The CreateLockedData local method takes as an argument our original SaveData class and returns a LockedData class.

This LockedData class has in the Hash string field a password that is hardcoded in our program. This password isn’t saved in any save file, is only being used to calculate the hash, by changing the json string in a way that its hash can be calculated only by people who know the password. An example in code:

const string PASSWORD = "MyPassword";

Then we serialize that class to json format by using the JsonUtility and we calculate its unique hash and store it in the place of our password in the lockedData.Hash field.

After that we need to serialize our class again to json format, because now instead of our password contains the calculated hash. Then we save the result in a file.

The SaveFilename() method, is a method that returns a string which is the path and name of our save file. For example it could be something like this:

string SaveFilename() => Application.persistentDataPath + "/" + "SaveFile.Sav";

Creating our Load method

bool TryLoadFile(out SaveData data)
{
    data = null;
    var lockedData = new LockedData();
        
    if (File.Exists(SaveFilename()))
        using (var reader = new StreamReader(SaveFilename()))
        {
            var json = reader.ReadToEnd();

            if (CheckData(json))
            {
                JsonUtility.FromJsonOverwrite(json, lockedData);
                data = lockedData.Data;
                return true;
            }
        }
        
    return false;
        
    bool CheckData(string json)
    {
        var dataForCheck = new LockedData();
        JsonUtility.FromJsonOverwrite(json, dataForCheck);

        var hash = dataForCheck.Hash;
        
        dataForCheck.Hash = PASSWORD;
        
        var jsonWithPassword = JsonUtility.ToJson(dataForCheck);

        var newHash = GetSHA256(jsonWithPassword);

        return hash == newHash;
    }
}

Our TryLoadFile method returns true if the load succeeds and false if the load fails, either because the file was not found or because the data in the file was changed externally. It is a pretty simple load method, that reads a file with json data and checks that json string with the CheckData() local method. If everything is ok, deserializes the json string to a LockedData instance and then returns through the out SaveData data parameter the Data field of that instance. The only difference from a typical Load method is the bool CheckData(string json) local method.

The CheckData() local method gets a json string as a parameter.

Then creates an instance of our LockedData class and deserializes the json string to this instance.

From this instance stores the Hash field in a temporary hash variable.

After that we change the instance’s Hash field with with a value that is our password and serialize it to json.

By getting the hash of that new instance that now contains the data plus our password in the place of the hash string, we can compare the two hashes.

Because both hashes were calculated by our data, plus a string that is equal to our const password they have to be the same. If they are, the method returns true, if not the method returns false, because that means that the data when we loaded the file is not the same as the data when we saved our file.

Conclusion

The above way is an easy way to check if someone has tampered with the save file and deny loading of files that have been changed. Although it uses the SHA256 hash algorithm and can’t be hacked, that doesn’t mean that our saves are completely safe from hacking. There are ways to tamper with the data, like someone reverse engineering our code and finding the password, or changing the data while in memory, before we save it to a file. There is no foolproof way to prevent those things, unless our game logic runs on a server.

This way is a fast and easy way to prevent people without knowledge or without the desire to spend more time, to easily change the contents of the file, while keeping it in a human readable format.

Thank you for reading, I hope you found this info useful and as always for questions and comments, use the comments section, or contact me directly via the contact form or email, also if you don’t want to miss any of the new articles, you can always subscribe to my newsletter or the RSS feed.


Follow me: