Unity/Mono Memory Hacking

This blog post will teach you about Unity(/Mono) Memory Management, finding classes, finding instances in memory, and doing the same without writing to the target process' memory at all.

Prerequisites

  • Basic C# knowledge
  • Advanced programming knowledge of any language
  • The ability to read and understand C
  • An installation & basic knowledge of a disassembler or decompiler (recommended: Ghidra)

Unity and Mono

Unity is a commonly used 3D game engine. In Unity, people can use C# to write their game logic. However, since C# needs a runtime, Unity provides two options for treating C# code when compiling a game: you can either use Mono or IL2CPP. Mono is a JIT runtime, compiling C# to machine code "just in time", on the executing device itself, while IL2CPP is an AOT compiler, which compiles C# code ahead of time. As far as I know, Mono is more commonly used than IL2CPP, because Mono is more flexible, supports more of .NET, and takes less time to compile. We will be focusing on Mono.

Unity uses a fork of Mono, which includes changes made by the Unity team themselves. For our work, we will be reading this code, just to avoid getting tangled in subtle differences. You should clone this repo and open it up in the IDE/text editor of your preference.

The Mono Metadata API

Mono is a huge project. It contains a C# compiler, a JIT compiler, a custom glib implementation, a CIL disassembler, a garbage collector, etc. Luckily for us, it also includes a Metadata API that we can use to poke around in it's insides.

Unluckily for us, the Metadata API is a huge undocumented mess. Because of this, we will be reading and searching code instead of documentation. Therefore, we are required to think about the structure of a C# program.

C# hosts load a C# runtime into a process, create a domain, load an assembly into the domain, and start executing it. The assembly contains all classes, codes, whatever else you can think of. So, if we want to mess with classes, we need to go for a ride down the chain:

  1. Find the root domain of the process
  2. Find our assembly(ies) of interest
  3. Find our class(es) of interest
  4. Find our instance(s) of interest
  5. ???
  6. Profit!

With a little bit of searching, we can find a lovely function called mono_get_root_domain. This function will be our entry-point to all our C# data. This function is quite simple: it returns a pointer to a MonoDomain:

/**
 * mono_get_root_domain:
 *
 * The root AppDomain is the initial domain created by the runtime when it is
 * initialized.  Programs execute on this AppDomain, but can create new ones
 * later.   Currently there is no unmanaged API to create new AppDomains, this
 * must be done from managed code.
 *
 * Returns: the root appdomain, to obtain the current domain, use mono_domain_get ()
 */
MonoDomain*
mono_get_root_domain (void)
{
	return mono_root_domain;
}
Snippet from Mono, licensed under MIT.

If you call this, you get the domain! Let's take a peek at the MonoDomain struct:

struct _MonoDomain {
	// ...
    
	guint32            state;
	/* Needed by Thread:GetDomainID() */
	gint32             domain_id;
	gint32             shadow_serial;
	GSList             *domain_assemblies;
	MonoAssembly       *entry_assembly;
	char               *friendly_name;
	GPtrArray          *class_vtable_array;
    
	// ...
};
Snippet from Mono, licensed under MIT.

Trust me, this struct is way bigger than the snippet I'm showing. Either way, the way to continue is clear: Go through the domain_assemblies list, and find the one we need. This will lead us to a struct called MonoAssembly.

struct _MonoAssembly {
	// ...
    
	char *basedir;
	MonoAssemblyName aname;
	MonoImage *image;
	
   	// ...
};
Snippet from Mono, licensed under MIT.

Again, I cut out all the boring stuff that we don't need. For some reason, Mono separates the code "image" from the assembly itself. So, after we jump down that pointer, we see... nothing about classes. Since there isn't anything obvious over here, we need to find a function that does the job. Luckily, we have something called mono_class_from_name. It takes a MonoImage, and returns a MonoClass.

Finding instances is a bit more complicated. It requires us to search through allocated memory, looking for the correct VTable pointer. While I won't go into memory searching, the VTable pointer can be found via the mono_class_vtable function. It takes a domain and a class, and returns the pointer we need to search for.

What we are actually looking for in memory are MonoObjects.

typedef struct MONO_RT_MANAGED_ATTR _MonoObject {
	MonoVTable *vtable;
	MonoThreadsSync *synchronisation;
} MonoObject;
Snippet from Mono, licensed under MIT.

These are the representation of an object. They start with the VTable pointer, but they are also larger than this, depending on what fields the class has. We can get information about the fields via MonoClass.fields.

/*
 * MonoClassField is just a runtime representation of the metadata for
 * field, it doesn't contain the data directly.  Static fields are
 * stored in MonoVTable->data.  Instance fields are allocated in the
 * objects after the object header.
 */
struct _MonoClassField {
	/* Type of the field */
	MonoType        *type;

	const char      *name;

	/* Type where the field was defined */
	MonoClass       *parent;

	/*
	 * Offset where this field is stored; if it is an instance
	 * field, it's the offset from the start of the object, if
	 * it's static, it's from the start of the memory chunk
	 * allocated for statics for the class.
	 * For special static fields, this is set to -1 during vtable construction.
	 */
	int              offset;
};
Snippet from Mono, licensed under MIT.

This lets us find the fields for our classes, and we can use offset to read the values of instances.

With this, we have theoretically fulfilled our first four steps. Let's see them in action.

Method A: Code injection

A great example of this method is Cheat Engine's MonoDataCollector. It injects a "Pipe Server" into the process, which finds Mono, grabs pointers to its functions, and exposes them via a socket of sorts. The code exists, is quite readable and simply recreatable, therefore I will not be going over this in this article. Go read it, if this is the method you want to use. However, sometimes, the game you are trying to mess with has a pesky rule that disallows memory writing. That's when Method B is useful.

Method B: Pure reading

The idea is, that you can reimplement the useful functions provided to us by the Metadata API. Since we can still read memory, we can make our new functions read values from the target process' memory. This way, you don't need to inject any code and write memory, if all you're looking for is, for example, reading game state.

Since we know which functions and structs we need from the API, we know what to recreate:

  1. Grab pointer to the root domain
  2. Recreate MonoDomain struct
  3. Recreate GSList struct, make iterable
  4. Recreate MonoAssembly struct
  5. Recreate MonoImage struct
  6. Recreate mono_class_from_name

Another good thing is, that we don't need to recreate the entirty of the structs and functions, only the parts we will need. For example, we couldn't care less about the MonoGCDescriptor of a MonoClass, therefore we don't need to implement it.

A painful part of this process is getting correct memory offsets. This will change for Windows, macOS, Linux, 32 or 64-bit, and it might change between compilations, or because of code changes.

Another issue is, that the pointer to the RootDomain changes in every rebuild of a game. One could implement some sort of metadata parsing to grab the pointer, but I chose not to.

To demonstrate the idea, here's a C# reimplementation of MonoDomain that I wrote in my PoC:

class Domain : RMObject
{
    internal static class Consts
    {
        public const int rootDomain = 0x5633e0; // changes with every build
        public const int assembliesOffset = 200;
        public const int idOffset = 0xBC;
    }

    public Int32 id
    {
        get
        {
            return MemoryManager.ReadInt32(ptr + Consts.idOffset);
        }
    }

    public List<Assembly> assemblies
    {
        get
        {
            IntPtr iter = MemoryManager.ReadPtr(ptr + Consts.assembliesOffset);
            var result = new List<Assembly>();

            while (iter != (IntPtr)0)
            {
                var ass = new Assembly(MemoryManager.ReadPtr(iter));
                result.Add(ass);
                iter = MemoryManager.ReadPtr(iter + IntPtr.Size);
            }

            return result;
        }
    }

    public static Domain GetRootDomain()
    {
        var monoModules = MemoryManager.process.Modules.Cast<ProcessModule>().Where(m => m.ModuleName == MemoryManager.monoDLL);
        var monoModule = monoModules.First();
        var monoBase = monoModule.BaseAddress;
        
        // Very weird compiler optimization or decompiler weirdness.
        // x86 Mono disassembly has a pointer to the root domain.
        // x64 Mono disassembly has a constant address for the root domain.
        return new Domain(MemoryManager.ReadPtr(monoBase + Consts.rootDomain));
    }

    public Domain(IntPtr ptr) : base(ptr) { }
}

MemoryManager is a static class that enables memory reading. It's platform-dependent. I'm using 64-bit Linux with procfs, so it parses memory mapping via /proc/$/maps, and I read via /proc/$/mem. RMObject is a basic parent class that has an IntPtr property called ptr, since every instance has a pointer somewhere in the target process' memory.

I am currently not releasing any further source code, mostly because my codebase is a bodged-together mess I made over a 3-day-long nerdsnipe. Maybe someday I will tidy it up enough to make it widely usable, but not now.

Method B has other advantages: If you're looking for a stealthier method of memory writing than injecting code into your target, you can use Method B to find instances, and then make small changes to the values you're after.

Finding constants

Method B requires a lot of constants. We need to know every relevant struct property's offset, and we need to know the root domain's pointer. The process is: take a look at each function you need, see what properties they access, and look for their offset with a disassembler. Here's an example with mono_class_vtable.

/**
 * mono_class_vtable:
 * \param domain the application domain
 * \param class the class to initialize
 * VTables are domain specific because we create domain specific code, and 
 * they contain the domain specific static class data.
 * On failure, NULL is returned, and \c class->exception_type is set.
 */
MonoVTable *
mono_class_vtable (MonoDomain *domain, MonoClass *klass)
{
	MonoError error;
	MonoVTable* vtable = mono_class_vtable_full (domain, klass, &error);
	mono_error_cleanup (&error);
	return vtable;
}

/**
 * mono_class_vtable_full:
 * \param domain the application domain
 * \param class the class to initialize
 * \param error set on failure.
 * VTables are domain specific because we create domain specific code, and 
 * they contain the domain specific static class data.
 */
MonoVTable *
mono_class_vtable_full (MonoDomain *domain, MonoClass *klass, MonoError *error)
{
	MONO_REQ_GC_UNSAFE_MODE;

	MonoClassRuntimeInfo *runtime_info;

	error_init (error);

	g_assert (klass);

	if (mono_class_has_failure (klass)) {
		mono_error_set_for_class_failure (error, klass);
		return NULL;
	}

	/* this check can be inlined in jitted code, too */
	runtime_info = klass->runtime_info;
	if (runtime_info && runtime_info->max_domain >= domain->domain_id && runtime_info->domain_vtables [domain->domain_id])
		return runtime_info->domain_vtables [domain->domain_id];
	return mono_class_create_runtime_vtable (domain, klass, error);
}
Snippet from Mono, licensed under MIT.

Opening up these functions with Ghidra, here's what we find:

undefined8 mono_class_vtable(undefined8 domain,undefined8 klass)

{
	undefined8 vtable;
	undefined error [104];
  
	vtable = mono_class_vtable_full(domain,klass,error);
	mono_error_cleanup(error);
	return vtable;
}

long ** mono_class_vtable_full(pthread_mutex_t *domain,long *klass,short *error)

{
	// ...
    
	iVar2 = mono_class_has_failure(klass,klass,0x71a);
	if (iVar2 != 0) goto LAB_002ccab0;
	puVar18 = (ushort *)klass[0x19];
	if (((puVar18 != (ushort *)0x0) && (*(int *)&domain[4].field_0x1c <= (int)(uint)*puVar18)) && (*(long ***)(puVar18 + (long)*(int *)&domain[4].field_0x1c * 4 + 4) != (long **)0x0)) {
		return *(long ***)(puVar18 + (long)*(int *)&domain[4].field_0x1c * 4 + 4);
	}
    
	// ... (mono_class_create_runtime_vtable gets embedded into this function, but we don't care about it, since we can't write memory anyways)
}

I know, I know, it looks unwieldy, incorrect, and horrible. But, with comparing the two sides, we come to the following conclusion:

MonoClass.runtime_info offset = 0x19
MonoClassRuntimeInfo.max_domain offset = 0
MonoDomain.id offset = ??? (we'll have get this from a simpler function)
MonoClassRuntimeInfo.domain_vtables offset = ??? (we'll have to deduce this)

We can deduce domain_vtables using a bit of knowledge about alignment. Let's take a look at MonoClassRuntimeInfo:

typedef struct {
	guint16 max_domain;
	/* domain_vtables is indexed by the domain id and the size is max_domain + 1 */
	MonoVTable *domain_vtables [MONO_ZERO_LEN_ARRAY];
} MonoClassRuntimeInfo;
Snippet from Mono, licensed under MIT.

Considering that we are running 64-bit, domain_vtables will be an 8-byte wide pointer. This means that it would make sense to 8-byte align it. Therefore, we can assume that the offset of domain_vtables is 8. (This is correct.)

For id, we can look at other functions. For example, there is a function called mono_domain_get_id. If we look at it in Ghidra, we see this:

undefined4 mono_domain_get_id(long domain)

{
	return *(undefined4 *)(domain + 0xbc);
}

Because of this, we know that the Domain ID offset is 0xBC.

By following this process, you can find every single offset that you need. Note that constants will be different based on OS and bit width. It would be possible to use a tool like pahole to get struct information with offsets, but sadly the Mono ELF file that Unity builds is stripped.

Epilogue

If you have any questions, or you are looking for guidance, you can contact me via your preferred method on my website. I look at Twitter the most.