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.
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
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.
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.
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.
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 TypeConfuseDelegate1.
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.
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.
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
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.
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!
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
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.
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.
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.
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.
This allows a deserialization attack to be performed which can execute arbitrary code remotely.
The following video demonstrates this attack from the victims perspective.
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.
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.
/*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<T> : 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<string, Type> allowedTypes = new Dictionary<string, Type>();
const int MAX_RECURSIONS = 4;
}
}