⟵ Blog
research

Deserialization Attacks in .Net Games

April 17, 2020

Today many games are developed using .Net or a modified .Net Runtime like the Unity engine. This of course means that deserialization vulnerabilities in .Net can also occur in these games.

Serialization functionality seems to mainly be used to store save games. Less frequent uses include user generated content or network transfer of information.

We’ve discovered several such vulnerabilities in games using our own tooling and will elaborate on a few of them as well as common patterns and pitfalls as well as mitigations here.

It goes without saying that all of the vulnerabilities discussed below have already been fixed.

Tooling and Approach

To find potential deserialization vulnerabilities we used our own NetGadget tool, which we plan to release in the near future.

To interactively analyze vulnerabilities and debug potential exploits we used dnSpy which is an amazing .Net decompiler, analyzer and debugger.

As a test for the NetGadget tool we scanned a complete Steam Library for usage of BinaryFormatter, as a potential sink for a deserialization attack, as seen in Figure 1:

Figure 1 - Scanning for BinaryFormatter recursively through a Steam Library

Our tool scans any .dll or .exe file it encounters for a method call matching the pattern given by the -m parameter and stores the information about them in one file per dll or exe.

Since we are looking for vulnerabilities in games, the Assembly-CSharp.dll files, which are where Unity stores a game's custom code, are of particular interest and are highlighted below.

Figure 2 - Output of scan

Each of the generated output files contains information about which functions were called and from where.

Of particular interest are of course calls to BinaryFormatter::Deserialize, since they are potential sinks for a deserialization attack.

Figure 3 - Extracted method call information

When a game using BinaryFormatter is discovered, we need to check for deserialization gadgets within it. Because Unity for instance only loads DLLs included in the games folders, it is necessary to verify which gadgets actually exist since not all known gadgets for that .Net version may be available.
Using NetGadget this process can be significantly sped up, since it can automatically scan for the existance of such gadgets.

Figure 4 for example shows a scan of the game Dragon Cliff. It uses BinaryFormatter insecurely, but the only potential gadget chain our tool reports is a false positive. This particular false positive generally shows up when .Net 2.0 is being used and gives a good indication that no gadget chains are available.

Figure 4 - No real gadget chain in Dragon Cliff

If you compare this to Figure 5, the scan of Tabletop Simulator, a lot more gadget candidates are shown, among them some well known gadget chains, like TempFileCollection or TypeConfuseDelegate^1^.

Figure 5 - Excerpt of gadget chain candidates of Tabletop Simulator

Once a potential entry point and the existence of deserialization gadgets have been confirmed, as a next step the potential entry points and their call trees need to be evaluated manually with dnSpy or a similiar tool.

This approach has proved efficient in quickly reducing the number of games potentially vulnerable to .Net deserialization attacks. The scanned steam library contained around 400 games (not all of them made in .Net). Eventually around 30 vulnerable games were identified of which 14 were exploitable.

Deserialization Attacks in Totally Accurate Battle Simulator

We will refrain from using screenshots of code that isn't ours. For this reason the following explanations of the details of certain vulnerabilities will discuss the control flow and function names, but not the details of the code in question.

Where is serialization used

Totally Accurate Battle Simulator by Landfall Games is a sandbox game that simulates battles with ragdoll physics.
BinaryFormatter is used to let Players build custom battles and campaigns and to share them over the Steam Workshop

NetGadget flagged the following methods as using the call to BinaryFormatter::Deserialize:

  • Landfall.TABS.Workshop.CampaignHandler::GetLoadedCampaignFromDisk
  • Landfall.TABS.Workshop.CampaignHandler::LoadFactionFromDisk
  • Landfall.TABS.Workshop.CampaignHandler::GetLoadedLayoutFromDisk
  • Landfall.TABS.Save.SteamSavesLoader::ReadLocal
  • Landfall.TABS.Save.SteamSavesLoader::OnRemoteStorageFileReadAsyncComplete

This leads to two distinct vulnerabilities. One involves the loading of save games and affects the last two functions, the other involves the loading of custom campaigns and battles and affects the first three.

Save Game Vulnerability

This vulnerability is short and sweet.
Since OnRemoteStorageFileReadAsyncComplete will eventually also call ReadLocal, it's enough to investigate what is required to exploit ReadLocal.

ReadLocal simply reads all bytes of the save file and deserializes them with BinaryFormatter. Thus it's enough to overwrite the save file to trigger a deserialization vulnerability.

After replacing the save game with a TypeConfuseDelegate payload and starting the game, we see Figure 6 - Success!

Figure 6 - Sample payload starting two cmd.exe processes

Workshop Vulnerability

This vulnerability is a little more involved, but can be used remotely through the Steam Workshop. Due to the nature of the workshop this vulnerability was only tested locally to prevent the accidental spread of the exploit.

The loading of a faction, campaign or layout from disk will deserialize the associated data. All of those functions are internally called either when a mod is already installed and is being loaded or through a custom content loader's LoadLocalCustomContent method.

Depending on the type of the content, it will invoke either of the vulnerable functions. The LoadLocalCustomContent itself is only called by the content loader's QuickRefresh method.

The QuickRefresh method is called from a lot of different sections in code, most interestingly by OnDownloadSuccess and OnSubSuccess as well as the onModBinaryInstalled callback.

This means that after a mod has been downloaded it will immediately be deserialized, allowing an attacker to trigger the vulnerability by uploading a mod to the Steam Workshop

Deserialization Attacks in Tabletop Simulator

We will refrain from using screenshots of code that isn't ours. For this reason the following explanations of the details of certain vulnerabilities will discuss the control flow and function names, but not the details of the code in question.

Where is Serialization Used

Tabletop Simulator by Berserk Games is a multiplayer physics sandbox that allows players to create and play tabletop games remotely.

In order to facilitate the exchange of games and the storing of table states the game employs two different methods of serialization: Newtonsofts' JsonSerializer and BinaryFormatter. The vulnerabilities we've found lie in the parts of the code that use BinaryFormatter.

BinaryFormatter is used to load what the code refers to as a PhysicsState.

The deserialization of such a physics state can either be triggered over the network through the LoadPromotedPhysicsState RPC call or through the loading of a mod that isn't in JSON format.

The RPC Vulnerability

Tabletop Simulator has an RPC mechanism that is, among other things, used to administrate a game host remotely.

One of these administrative functions is LoadPromotedPhysicsState.
It gets passed an array of bytes which are then used to call GetPhysicsState, which in turn calls BinaryFormatter::Deserialize with the data. To call this function two criteria need to be met.

  • Firstly it may only be called on the host of a game.
  • Secondly it may only be called by users with admin privileges in that hosts session.
  • Requiring administrative privileges on a server to exploit the host seems restrictive at first. A server’s host can however migrate the hosting of the game to another client on the server. This causes two main changes in user roles in the game.
  • Firstly it makes the client, that was migrated to, a host, fulfilling the first requirement.
  • Secondly the former host is promoted to admin in the new hosts session, fulfilling the second requirement.
  • Thus a malicious host can meet both requirements by migrating the host to their target. They can then send an arbitrary byte array to the newly created host’s LoadPromotedPhysicsState method, triggering deserialization with arbitrary data.

This allows a deserialization attack to be performed which can execute arbitrary code remotely.

The following video demonstrates this attack from the victims perspective.

Deserialization Attacks in .Net Games - TableTop Simulator Proof of Concept

This content is hosted by YouTube. By showing the external content you accept the YouTube privacy policy.

The Mod Vulnerability

In Tabletop Simulator users can upload their table setups and scripts into the Steam Workshop to share them with the community. These mods can be subscribed to and downloaded to use by any player. When a mod has finished downloading it will eventually be deserialized.
When the download from the Steam Workshop has completed the CallResultWorkshopReadDownload callback method will be invoked.
This method in turn calls SerializationScript::Save which attempts to load the file contents with the GetPhysicsState method first and the JSON Serializer second.
GetPhysicsState then invokes BinaryFormatter::Deserialize.

A malicious attacker is able to upload a mod that will execute arbitrary code when deserialized, potentially allowing it to spread through the Steam Workshop.

Mitigations

In its default configuration BinaryFormatter in .Net is insecure. Several well known gadget chains exist that can be easily used to gain arbitrary code execution.

BinaryFormatter does however allow the definition of a Binder class which handles the resolution of types used in the Serialization and Deserialization processes.

Using such a Binder, whitelisting of expected types can be facilitated with relative ease, providing resilience against deserialization gadgets. This method was used to fix all of the vulnerabilities mentioned above. Following is an example Binder that allows the restriction of deserialized types to those required to deserialize the generic type T. It is provided under the GNU All-permissive license.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
/*Copyright 2020, Nils Ole Timm - modzero

Copying and distribution of this file, with or without modification,
are permitted in any medium without royalty,
provided the copyright notice and this notice are preserved.
This file is offered as-is, without any warranty.*/

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Reflection;
using System.Collections;
using System.Runtime.Serialization;
using System.Runtime.Serialization.Formatters.Binary;
using System.Runtime.Serialization.Formatters;

namespace StronglyTypedSerializationBinder
{
    //A binder can control which types are used in deserialization
    //This binder only allows the deserialization of objects of the expected type
    //and its member fields
    public class StronglyTypedBinder&ltT> : SerializationBinder
    {
        //This function is called when the deserialization needs to find the runtime type
        //associated with the stored type of the serialized data
        public override Type BindToType(string assemblyName, string typeName)
        {
            Type type;
            //Compute the allowed types for this deserialization binder if they haven't already
            //been computed
            if (allowedTypes.Count == 0)
            {
                ComputeAllowedTypes();
            }
            if (allowedTypes.TryGetValue(typeName, out type))
                return type;
            //If the type isn't found return typeof(object) to cause harmless default behaviour
            return typeof(object);
        }

        //Calculate which types should be allowed for type T
        private void ComputeAllowedTypes()
        {
            Type baseType = typeof(T);
            //Add base type and types of fields and their fields recursively up to maximum depth
            AddAllowedTypesRecursive(baseType, 0);
        }

        //Recursively add types of fields to list
        private void AddAllowedTypesRecursive(Type t, int depth)
        {
            //Stop when maximum depth of recursion is reached
            if (depth > MAX_RECURSIONS)
                return;
            //Add base type if not already added
            if (!allowedTypes.ContainsKey(t.FullName))
                allowedTypes.Add(t.FullName, t);
            //If type is primitive no need to recurse further
            if (t.IsPrimitive)
                return;
            //If type is generic, handle fields and generic parameters and do special handling of Dictionary
            else if (t.IsGenericType)
            {
                Type[] genericArguments = t.GetGenericArguments();
                foreach (Type t2 in genericArguments)
                    AddAllowedTypesRecursive(t2, depth + 1);
                foreach (FieldInfo fi in t.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))
                {
                    AddAllowedTypesRecursive(fi.FieldType, depth + 1);
                }
                if(t.GetGenericTypeDefinition() == typeof(Dictionary<,>))
                {
                    AddAllowedTypesRecursive(Type.GetType("System.Collections.Generic.GenericEqualityComparer`1").MakeGenericType(t.GetGenericArguments()[0]), MAX_RECURSIONS);
                    AddAllowedTypesRecursive(typeof(KeyValuePair<,>).MakeGenericType(genericArguments), MAX_RECURSIONS);
                }
            }
            //If type is an array add the type of the element
            else if (t.IsArray)
            {
                AddAllowedTypesRecursive(t.GetElementType(), depth + 1);
            }
            //In all other cases add the types of all fields
            else foreach (FieldInfo fi in t.GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))
                {
                    AddAllowedTypesRecursive(fi.FieldType, depth + 1);
                }
        }

        static Dictionary&ltstring, Type> allowedTypes = new Dictionary&ltstring, Type>();
        const int MAX_RECURSIONS = 4;
    }
}

Other News

All news ⟶