Practical IL2CPP Reverse Engineering: Extracting Protobuf definitions from applications using protobuf-net (Case Study: Fall Guys)
DISCLAIMER: The following information and source code is provided for educational purposes only. I do not condone cheating in online multiplayer games and expressly discourage this behaviour. This tutorial is intended to demonstrate the thought processes and techniques involved in reverse engineering. It is not intended to enable cheating, the modification of gameplay or any interference or alteration of any server-side components of the analysed product in any way whatsoever. Check your local laws before using this software. At the time of writing I have never connected to a Fall Guys network endpoint or launched the client.
You can download the full source code for this tutorial from the Il2CppProtoExtractor-FallGuys GitHub repo.
[Updated 7th December 2020: added instructions for using Il2CppInspector’s NuGet package in preference to creating a git clone; added link to commit with an example showing how to find attribute values automatically with a disassembly framework]
Introduction
Il2CppInspector provides several powerful tools to interact with IL2CPP application code and data via static analysis:
- A low-level binary representation (
Il2CppInspector
) which allows you to query the IL2CPP metadata in its original format - A .NET type model (
TypeModel
) which provides a Reflection-style API to all of the types in the application - An application model (
AppModel
) which provides an API to query the compiled C++ types, methods and other symbols in the binary, including those not represented by .NET types
In this article, we will leverage the .NET type model to inspect a game and derive a Google Protobuf .proto
file encapsulating its network protocol.
Pre-requisites:
- Knowledge of .NET, C# and LINQ
- Basic awareness with what IL2CPP is and what it does (no in-depth knowledge needed)
- Basic awareness of what Google Protobuf is
- Basic knowledge of how to use a disassembler such as IDA and how to read basic x86-64 assembly code
- An inquisitive mind
In this article, you will learn:
- How to set up a new Visual Studio project which uses Il2CppInspector
- How to load an IL2CPP application and create a type model
- How to use LINQ to query .NET types, interfaces, fields, properties, generic type arguments, arrays and attributes in an IL2CPP application
- How to extract constructor arguments to custom attributes not retained by IL2CPP in the metadata
- How to transform all of the combined data into a
.proto
file
The game at hand today is Fall Guys published by Devolver Digital, a Battle Royale-style party game where 60 players race around in bright colorful maps vying for victory. The game requires an upfront purchase and then has microtransactions on top. Being asked to pay more for the rest of the content when I’ve already purchased a game makes me very cantankerous, and Fall Guys also happens to be compiled with IL2CPP, which makes it the perfect target for some reverse engineering fun!
Although I’m using Fall Guys for this example, many of the techniques described below are applicable to any game deployed with IL2CPP and using Protobuf.
I bought the game. What do I do now?
At this point we’re going to pretend we were in the position I was in 2 days ago: we’d like to look at the game’s network protocol but we know literally nothing about it. All we have are the game files.
Your first job is to run Il2CppInspector (at the CLI or GUI) using Fall Guys’s GameAssembly.dll
and FallGuys_client_Data/il2cpp_data/Metadata/global-metadata.dat
files from the game’s Steam directory as input. All Windows IL2CPP games use this same folder layout so you’ll always be able to find these files in the same relative paths for any game.
Il2CppInspector produces a lot of output files, but the ones we are interested in today are:
- The C# types file (
types.cs
by default). This will let us cruise around every type and method declaration in the game looking for items of interest! - The IDA/Ghidra Python script (
il2cpp.py
by default). This is actually optional, and if you’re not using a disassembler you don’t need it at all, but if you are, running this script in your disassembler will make the game code much easier to read.
Tip: Need a disassembler? Try downloading IDA Freeware Edition.
Tip: Visual Studio Code’s folder browser provides a convenient way to browse the .cs
files generated by Il2CppInspector. For easy searching in this use case, I recommend selecting File per namespace in the GUI or use the --layout=namespace
option from the CLI when generating the files.
Determining which networking library is in use
When we first inspect an application, we don’t really know what we’re looking for exactly and have to use some intuition. This essentially comes from experience, and as you get more reverse engineering (RE) adventures under your belt you’ll gradually become familiar with commonly used tools and libraries by software developers.
There are a couple of obvious starting points:
- Watch the network traffic with a tool like WireShark to see if there is anything recognizable (requires knowledge of network protocols and doesn’t always work if the traffic is encrypted)
- Grep (search) through the
.cs
files produced by Il2CppInspector for words like “packet”, “client”, “server”, “network”, “message”, “send”, “receive”, “protocol” etc. You can also search for the names of common networking libraries such as Lidgren, Photon PUN or indeed Google Protobuf. This is a very crude brute-force approach, but when you’ve got nothing, you’ve got nothing to lose!
Tip: Research is 90% of hacking. If you don’t know what the popular networking libraries are, simply Google “most popular c# network libraries for unity games” and browse through a few of the results.
Searching through the generated C# code we can quickly see there is a namespace called Google.Protobuf
and another one just called ProtoBuf
, so it’s a really good bet that at least some of the network traffic is using Protobuf. Note I say “at least some” because you should never assume anything! Indeed, Fall Guys uses Protobuf for login and matchmaking but uses a different, UDP transport layer-based format for in-game simulation updates (we won’t be covering that here).
One thing that makes Protobuf a little bit different from the other options is that Protobuf itself isn’t actually a specific library but merely the specification of a format. While Google does have its own implementations for a variety of languages, a number of other implementations have also cropped up for .NET including SilentOrbit and protobuf-net.
Protobuf compilers all work in a similar way: they take one or more .proto
files describing various network message formats, and generate C# classes which represent those messages together with methods to serialize and deserialize them. Each produces its own flavour of code so we’re going to need to know the specific implementation our game is using, but fortunately this is easy: all of the implementations are open source and we can simply examine some typical code they generate and look for similarities in our generated C# code.
This is something you will generally research yourself, but to cut a long story short:
- Google’s implementation always has a field called
_parser
in every message class - SilentOrbit’s implementation always has a method called
DeserializeLengthDelimited(Stream stream)
for every message class - protobuf-net’s implementation always decorates each message class with a
[ProtoContract]
attribute (there is an exception to this where you can configure attribute-less serialization behaviour using protobuf-net’sRuntimeTypeModel
object, but this is not applicable here)
We are essentially looking for the simplest possible reliable indicator that a particular library is in use. It doesn’t matter what the indicator is, as long as we can reliably find it and use it to identify which classes in the application are Protobuf message classes.
Again I want to stress that research is critical: I didn’t conjure this information up by magic; I found it by downloading each tool and experimenting with simple test cases, and by reading the documentation for each tool. This kind of work is often the most time-consuming part and typically gets overlooked in guides like this, but it’s an essential skill – take your time!
As we look through the generated C# files, we can indeed see a large number of classes annotated with the [ProtoContract]
attribute, so we can safely conclude that we are looking at a usage of the protobuf-net library. We make a mental note of this as it will be important later.
Creating a project which uses Il2CppInspector
Looking through all the message classes in our favourite text editor (and by that I obviously mean Visual Studio Code) is nice, but if we’re looking to interpret the data on the wire from outside the application or write a replacement client or server, we’re going to need to reconstruct the original .proto
files.
You could do this by hand by painstakingly looking through all the C# generated by Il2CppInspector, but that’s not going to be a good time. Plus, in-game network protocols frequently change with new patches, so we really want to automate the process of generating these files as much as possible.
The real power of Il2CppInspector lies not in the files it produces, but in how you can integrate it into your own RE tools. Let’s do this now by creating a new project and setting it up for use with Il2CppInspector.
- In Visual Studio (the full fat version, not VS Code – we’re going to need our big girl/boy pants for this), choose File -> New Project…, select Console App (.NET Core) as the project type and give it a name that makes you feel like a hacker warlord (I went with
ConsoleApp1
) - Right-click on the project in Solution Explorer and choose Manage NuGet Packages…
- In the Browse tab, search for
NoisyCowStudios.Il2CppInspector
and install the latest version - To confirm that it works, add:
using Il2CppInspector;
to the top ofProgram.cs
in your console app project and compile.
Info: If you prefer to use the latest commit of Il2CppInspector directly from GitHub, replace steps 2 and 3 above as follows:
- Add the Il2CppInspector GitHub repo. To do this, open a command prompt,
cd
into your solution folder and type:git clone --recursive https://github.com/djkaty/Il2CppInspector
(note: if you have created a GitHub repo for your solution, usegit submodule add
instead[1]) - Right-click on the solution in Solution Explorer and choose Add -> New Solution Folder. Call it
Submodules
. - Right-click
Submodules
and choose Add -> Existing Project… then navigate through your solution folder and selectIl2CppInspector/Il2CppInspector.Common/Il2CppInspector.csproj
. Il2CppInspector will now appear in Solution Explorer. - Repeat this process and this time select
Il2CppInspector/Bin2Object/Bin2Object/Bin2Object.csproj
. - Right-click your new console app project in Solution Explorer and choose Add -> Project Reference… Tick the box for Il2CppInspector in the dialog that opens and click OK (you don’t need to tick the box for Bin2Object).
If all goes well, you should see compile output like this:
1>------ Rebuild All started: Project: Bin2Object, Configuration: Debug Any CPU ------
1>Bin2Object -> H:\test\ConsoleApp1\Il2CppInspector\Bin2Object\Bin2Object\bin\Debug\netstandard2.1\Bin2Object.dll
2>------ Rebuild All started: Project: Il2CppInspector, Configuration: Debug Any CPU ------
2>Il2CppInspector -> H:\test\ConsoleApp1\Il2CppInspector\Il2CppInspector.Common\bin\Debug\netstandard2.1\Il2CppInspector.Common.dll
3>------ Rebuild All started: Project: ConsoleApp1, Configuration: Debug Any CPU ------
3>ConsoleApp1 -> h:\test\ConsoleApp1\ConsoleApp1\bin\Debug\netcoreapp3.1\ConsoleApp1.dll
========== Rebuild All: 3 succeeded, 0 failed, 0 skipped ==========
(see commit for these changes)
Creating the type model
Before we can do any analysis, we need to process the binary and metadata files with Il2CppInspector. If we copy those files to our project output directory, this can be written as follows:
using Il2CppInspector.Reflection;
// Set the path to your metadata and binary files here
public static string MetadataFile = @"global-metadata.dat";
public static string BinaryFile = @"GameAssembly.dll";
// First we load the binary and metadata files into Il2CppInspector
// There is only one image so we use [0] to select this image
Console.WriteLine("Loading package...");
var package = Il2CppInspector.Il2CppInspector.LoadFromFile(
BinaryFile, MetadataFile, silent: true)[0];
// Now we create the .NET type model from the package
// This creates a .NET Reflection-style interface we can query with Linq
Console.WriteLine("Creating type model...");
var model = new TypeModel(package);
This code will take a few seconds (or more) to run. Once finished, Il2CppInspector
gives access to the binary and metadata as a single package and allows low-level querying of all the metadata structures and tables in the application. TypeModel
takes all of this information and converts it into a high level .NET type model.
Note: Some binary files, for example Apple Universal Binaries (“Fat Mach-O”) and APK files may contain multiple IL2CPP images for different architectures. Il2CppInspector.LoadFromFile
returns an array of Il2CppInspector
s – one for each image. If you know your binary only contains one image, just use [0]
or First()
to select it.
(see commit for these changes)
The simplest possible output: all message names
Let’s start with a basic sanity check (I’m told this is politically incorrect now – please send complaints to /dev/null
) by printing out the names of all of the Protobuf message classes – that is, all of the classes with a [ProtoContract]
attribute.
First, we need to get a handle to ProtoContractAttribute
(the naming convention of C# attributes dictates that the Attribute
suffix is elided when using it in an attribute decorator, so the actual name of the type is ProtoContractAttribute
).
// All protobuf messages have this class attribute
var protoContract = model.GetType("ProtoBuf.ProtoContractAttribute");
Now we can enumerate every type which has this attribute:
using System.Linq;
// Get all the messages by searching for types with [ProtoContract]
var messages = model.TypesByDefinitionIndex
.Where(t => t.CustomAttributes.Any(a => a.AttributeType == protoContract));
TypeModel
is designed to be queried with LINQ and as you can see it’s trivially easy to drill down to the types we want. Here we go through TypesByDefinitionIndex
– which includes exactly the types you see in Il2CppInspector’s C# output – look at the attributes of each one and select any that have ProtoContractAttribute
.
NOTE: TypeModel
includes various other type enumerators including TypesByReferenceIndex
, GenericParameterTypes
, TypesByFullName
, and Types
(which includes everything), as well as the GetType()
method.
Normally we would iterate over types with Types
, however for Fall Guys this includes some constructed generic types decorated with [ProtoContract]
that we don’t want. Whereas Types
includes every compiled generic type with concrete type arguments, TypesByDefinitionIndex
only includes types as they have been defined by the developer in source code.
Another way to avoid this problem in this particular application is to still iterate over Types
but include the condition !t.IsGenericType
in the LINQ Where query.
Now we have a list of message types in messages
, we just need to iterate over them and print their names:
// Print all of the message names
foreach (var message in messages)
Console.WriteLine(message.FullName);
And that’s it! In total, 4 lines of code to retrieve and output the name of every Protobuf message class in the binary. Not bad – now let’s turn it up a notch!
Tip: There are quite a few ways you can fetch type names, depending on the situation
FullName
– the fully-qualified name of the type (including its namespace) with decorators appliedName
– the unscoped CTS name of the type with decorators applied. This is what you will use most often along withFullName
BaseName
– the unscoped (ie. not fully-qualified with a namespace) CTS name of the type with all decorators such as nested type indicators and generic type parameter suffixes removedCSharpName
– the name as it would appear in C# source code –ref
keywords and decorators for arrays, pointers, generic arguments and nullable types are applied. CTS primitive types are displayed as C# friendly type names.UnmangledBaseName
– the base name with CTS names demangled, eg. ifBaseName
isList`1
, thenUnmangledBaseName
isList
.GetCSharpTypeDeclarationName(bool includeVariance)
– the name as it would appear in a type declaration, either as the type being defined or as one of the specified derived types or interfaces. The same asCSharpName
except that top-level names are not converted from CTS primitive types (nested generic type arguments are), and generic type arguments use a fully-qualified scope. IfincludeVariance
is true, the in and out covariant and contravariant decorators will be emitted if applicable.GetScopedCSharpName(Scope usingScope = null, bool omitRef = false, bool isPartOfTypeDeclaration = false)
– likeCSharpName
except that Il2CppInspector tries really hard to emit the name using the least amount of scope qualifiers necessary to access it from the local scope specified inusingScope
.
(see commit for these changes)
Houston, We Have A Problem
When we construct complex output from a complex input, we want to start simple and gradually implement all of the things we need to create the complete output. This is usually a more manageable approach than trying to do everything in one pass. We’ve found all of the messages and their names, let’s now look at a more or less randomly chosen message from the generated C#:
[ProtoContract] // 0x0000000180048B40-0x0000000180048B70
public class ItemPaymentLineDto // TypeDefIndex: 9137
{
// ... irrelevant stuff elided ...
// Properties (getter and setter addresses elided)
[ProtoMember] // 0x0000000180001E80-0x0000000180001EA0
public string Type { get; set; }
[ProtoMember] // 0x0000000180005500-0x0000000180005520
public string Id { get; set; }
[ProtoMember] // 0x0000000180005A20-0x0000000180005A40
public int Quantity { get; set; }
}
We know from the Protocol Buffers Language Guide[3] that each message contains zero or more fields and that each field has a type, a name and a field number (the field numbers are sent over the wire before each field value to distinguish each part of the message; the names are not sent and are only for humans). We also know from protobuf-net’s documentation that each field or property to be serialized must be decorated with a [ProtoMember]
attribute. So we know that the above class has three message fields which should be encoded something like this:
message ItemPaymentLineDto {
string Type = ?;
string Id = ?;
int32 Quantity = ?;
}
So far so good, but where are the field numbers? Let’s look at protobuf-net’s documentation to find out. From protobuf-net’s Github page:

The field numbers are specified as constructor arguments to ProtoMemberAttribute
. As we can see, these arguments are not included in Il2CppInspector’s output, and this turns out to be the major roadblock in our plan. To find out why, we need to delve deep into the murky inner workings of IL2CPP.
Custom Attribute Generators in IL2CPP
Almost all the information you could wish for is available from the vast amount of metadata IL2CPP retains. Unfortunately, arguments to attribute constructors are handled differently.
When you decorate an item with an attribute like [Foo(123)]
, IL2CPP doesn’t store the argument 123 in metadata. Instead, it creates a new C++ function which is essentially a trampoline that calls Foo
‘s constructor with the argument 123. This function is called a custom attribute generator (I’ll call them CAGs for short but this is not an official acronym) and always has the following signature:
void xxx_CustomAttributesCacheGenerator(CustomAttributesCache *)
where xxx
is the IL2CPP internal identifier for the item on which the attribute is set (not the type of the attribute).
If the exact same set of constructor arguments for the exact same attributes are used again elsewhere, the same CAG will be called when the using item is invoked.
Moreover if multiple attributes are decorating a single item, IL2CPP will generate a single CAG for the entire set of attributes. This is a big deal because it means that every unique used set of attributes with unique constructor arguments gets its own CAG.
This is best illustrated with a fabricated example:
[Foo(1)] public int field1; // Generate CAG 'A'
[Foo(2)] public int field2; // Generate CAG 'B'
[Foo(1)] public int field3; // Reuse CAG 'A'
[Foo(1, 5)] public int field4; // Generate CAG 'C'
[Bar(1)] public int field5; // Generate CAG 'D'
[Foo(1), Bar(1)] public int field6; // Generate CAG 'E'
[Foo(1), Bar(1)] public int field7; // Reuse CAG 'E'
[Foo(2), Bar(1)] public int field8; // Generate CAG 'F'
During compilation, IL2CPP will generate a CAG for field1
and field2
.
field3
uses the same attribute as field1
so the CAG for field1
will be re-used.
field4
uses a new 2nd argument so it gets its own CAG, as does field5
which uses a previously unseen attribute.
At first glance it seems that field6
uses the same attributes as field3
and field5
combined, but since they have never occurred in this combination, and because an item can only have one CAG, a new CAG is generated for field6
.
field7
uses the exact set of attributes with the same arguments as field6
so the CAG for field6
will be re-used.
field8
uses a previously seen set of attributes (in field6
and field7
) but one of the arguments is different, so a new CAG is generated for field8
.
Custom Attribute Generator object code
Let’s fire up a disassembler and look at the code for a ProtoMember
CAG. Il2CppInspector gives all of the CAG address in the C# output in comments after each attribute, and it will also demarcate and type the functions in IDA/Ghidra. The first property in ItemPaymentLineDto
above has a CAG at 0x180001E80
, which looks like this:
.text:0000000180001E80 ; void __stdcall ProtoMemberAttribute_CustomAttributesCacheGenerator(CustomAttributesCache *)
.text:0000000180001E80 ProtoMemberAttribute_CustomAttributesCacheGenerator proc near
.text:0000000180001E80 ; DATA XREF: .rdata:00000001821F3EE0↓o
.text:0000000180001E80 ; .rdata:00000001821F4280↓o ...
.text:0000000180001E80 mov rcx, [rcx+8]
.text:0000000180001E84 xor r8d, r8d
.text:0000000180001E87 mov rcx, [rcx]
.text:0000000180001E8A lea edx, [r8+1]
.text:0000000180001E8E jmp ProtoMemberAttribute__ctor
.text:0000000180001E8E ProtoMemberAttribute_CustomAttributesCacheGenerator endp
This doesn’t look too bad. The x64 stdcall calling convention[4] is used which means that the first four arguments (ignoring floating point numbers) are stored in RCX, RDX, R8 and R9.
Object methods always take a hidden this
argument as the first parameter when compiled to executable code, because everything becomes a top-level function and objects don’t exist, so a pointer to the object’s data must be included in every function call that equates to one of the object’s methods so we know which instance we are working with. Therefore in this case, RCX is this
.
XORing a register with itself is a common way of setting it to zero, therefore R8 is zero. The key instruction is lea edx, [r8+1]
which loads 0+1 (ie. 1) into EDX (which is the bottom 32 bits of RDX). Therefore EDX is 1, and this is the argument that will be passed to ProtoMemberAttribute
‘s constructor.
The function ends with a jump (jmp
) to ProtoMemberAttribute__ctor
, and we conclude that this function’s behaviour is equivalent to calling the constructor ProtoMember(1)
.
Let’s have a look at the CAG for the second property in ItemPaymentLineDto
:
.text:0000000180005500 ; void __stdcall ProtoMemberAttribute_CustomAttributesCacheGenerator_6442472704(CustomAttributesCache *)
.text:0000000180005500 ProtoMemberAttribute_CustomAttributesCacheGenerator_6442472704 proc near
.text:0000000180005500 ; DATA XREF: .rdata:00000001821F4288↓o
.text:0000000180005500 ; .rdata:00000001821F42D0↓o ...
.text:0000000180005500 mov rcx, [rcx+8]
.text:0000000180005504 xor r8d, r8d
.text:0000000180005507 mov rcx, [rcx]
.text:000000018000550A lea edx, [r8+2]
.text:000000018000550E jmp ProtoMemberAttribute__ctor
.text:000000018000550E ProtoMemberAttribute_CustomAttributesCacheGenerator_6442472704 endp
That looks awfully similar to the previous CAG. In fact, it’s identical, with one exception: lea edx, [r8+2]
. This time we are loading the value 2 instead of 1. Therefore we can deduce that this function is equivalent to calling the constructor ProtoMember(2)
.
Let’s check the third and final property:
.text:0000000180005A20 ; void __stdcall ProtoMemberAttribute_CustomAttributesCacheGenerator_6442474016(CustomAttributesCache *)
.text:0000000180005A20 ProtoMemberAttribute_CustomAttributesCacheGenerator_6442474016 proc near
.text:0000000180005A20 ; DATA XREF: .rdata:00000001821F4290↓o
.text:0000000180005A20 ; .rdata:00000001821F42E8↓o ...
.text:0000000180005A20 mov rcx, [rcx+8]
.text:0000000180005A24 xor r8d, r8d
.text:0000000180005A27 mov rcx, [rcx]
.text:0000000180005A2A lea edx, [r8+3]
.text:0000000180005A2E jmp ProtoMemberAttribute__ctor
.text:0000000180005A2E ProtoMemberAttribute_CustomAttributesCacheGenerator_6442474016 endp
Well it sure looks like we found our field numbers! Here we have another identical function except that this time lea edx, [r8+3]
sets the constructor argument to 3, so this function is equivalent to calling ProtoMember(3)
.
It’s cold and dark down here in the Mariana Trench.
Acquiring the attribute constructor arguments programmatically
Now we know how to get the argument to any call to ProtoMemberAttribute
‘s constructor by hand, we need to indulge in some kind of dark magic to automate this process. A quick look at the hex view of the functions above makes the solution clear:
0000000180001E80 48 8B 49 08 45 33 C0 48 8B 09 41 8D 50 01 E9 5D
0000000180005500 48 8B 49 08 45 33 C0 48 8B 09 41 8D 50 02 E9 DD
0000000180005A20 48 8B 49 08 45 33 C0 48 8B 09 41 8D 50 03 E9 BD
The field number (which is part of the lea
instruction) is always 13 (0x0D) bytes offset from the start of the function.
TypeModel
provides an API to fetch all of the CAGs that call constructors on a specific type (including those which call constructors on multiple types including the specified type):
// Get all of the CAGs for ProtoMember so we can determine field numbers
var atts = model.CustomAttributeGenerators[protoMember];
atts
is now a List<CustomAttributeData>
, which among other things provides the virtual address of the CAG and its function body. We can use these with LINQ projection to create a dictionary which maps ProtoMemberAttribute
CAG addresses to field numbers:
// Create a mapping of CAG virtual addresses to field numbers
vaFieldMapping = atts.Select(a => new {
VirtualAddress = a.VirtualAddress.Start,
FieldNumber = a.GetMethodBody()[0x0D]
})
.ToDictionary(kv => kv.VirtualAddress, kv => kv.FieldNumber);
// Print all attribute mappings
foreach (var item in vaFieldMapping)
Console.WriteLine($"{item.Key.ToAddressString()} = {item.Value}");
VirtualAddress
is a tuple (ulong Start, ulong End)
giving the start and end virtual addresses of the function, and GetMethodBody()
retrieves the actual object code as byte[]
. By indexing 0x0D bytes into this, we retrieve the field number.
Tip: The class TypeModel.MethodBase
also includes VirtualAddress
and GetMethodBody()
. You can use these to inspect regular .NET methods, constructors, property getters and setters, and events in the same way. These APIs are also available for MethodInvoker
which handles Method.Invoke thunks – another IL2CPP idiosyncracy.
Note: The end address in VirtualAddress
is exclusive, ie. it points to the byte after the final byte of the function.
Tip: the ToAddressString()
extension method can be used on ulong
s and VirtualAddress
tuples to format address values into hex values automatically formatted to 8 or 16 digits depending on the address length, and with a 0x
prefix. For VirtualAddress
tuples, the start and end addresses will be output as a range like 0x12345678 - 0x12345699
.
When we run the code above, we get this output:
0x0000000180001E80 = 1
0x0000000180005500 = 2
0x0000000180005A20 = 3
0x0000000180005BF0 = 4
0x0000000180005E20 = 5
0x0000000180055270 = 139
0x00000001800072C0 = 6
0x0000000180010270 = 7
0x0000000180005820 = 8
0x0000000180005660 = 8
0x000000018002FCB0 = 8
0x0000000180044480 = 9
0x0000000180044580 = 10
0x0000000180044620 = 11
0x0000000180044730 = 12
0x0000000180044C10 = 13
Success! …ish. The output seems mostly reasonable, but what is going on at 0x180055270
? It seems like the Mariana Trench has more secrets left to uncover – We Need To Go Deeper.
Note: This solution to finding attribute constructor arguments from CAGs is both architecture dependent, compiler dependent, and build settings dependent. Therefore it is very fragile, and must be tailored to individual target applications. This is the reason why Il2CppInspector does not include a protobuf-net extractor out of the box.
(see commit for these changes)
Custom Attribute Generators with multiple attributes
We dip back into our disassembler to check out the CAG at 0x180055270
:
.text:000000180055270 ; void __stdcall ObsoleteAttribute_CustomAttributesCacheGenerator_6442799728(CustomAttributesCache *)
.text:0000000180055270 ObsoleteAttribute_CustomAttributesCacheGenerator_6442799728 proc near
.text:0000000180055270 ; DATA XREF: .rdata:00000001821F43A8↓o
.text:0000000180055270 ; .pdata:0000000182EA6868↓o
.text:0000000180055270 push rbx
.text:0000000180055272 sub rsp, 20h
.text:0000000180055276 mov rbx, rcx
.text:0000000180055279 xor r8d, r8d ; method
.text:000000018005527C mov rcx, [rcx+8]
.text:0000000180055280 lea edx, [r8+5] ; tag
.text:0000000180055284 mov rcx, [rcx] ; this
.text:0000000180055287 call ProtoMemberAttribute__ctor
.text:000000018005528C mov rax, [rbx+8]
.text:0000000180055290 lea rcx, aFoundInsideExt ; "Found inside `ExtraIdentities`."
.text:0000000180055297 mov rbx, [rax+8]
.text:000000018005529B call il2cpp_string_new_wrapper
.text:00000001800552A0 mov rdx, rax ; message
.text:00000001800552A3 xor r8d, r8d ; method
.text:00000001800552A6 mov rcx, rbx ; this
.text:00000001800552A9 add rsp, 20h
.text:00000001800552AD pop rbx
.text:00000001800552AE jmp ObsoleteAttribute__ctor_1
.text:00000001800552AE ObsoleteAttribute_CustomAttributesCacheGenerator_6442799728 endp
This has similarities to the other CAGs we examined but we can see that this constructs two attributes: ProtoMember(5)
and Obsolete("Found inside 'ExtraIdentities'.")
(we can confirm this by checking the C# types output file), and the compiler has added some extra instructions at the start which break our ugly hack.
Il2CppInspector does offer some tooling in AppModel
designed to be used with disassembly frameworks like Capstone.NET to help with problems like this, but for this article we’ll take the lazy approach and fix it manually:
vaFieldMapping[0x180055270] = 5;
Note that unlike all of the code up to this point, this will unfortunately break whenever the game is patched, because the addresses will change.
Let’s not be too hasty though. Are we sure all of the other field numbers are correct?
We can find out by checking every CAG by hand in a disassembler, but that would defeat the point of making an automatic mapping. Instead, let’s call on Il2CppInspector to help us once again by finding out which of the ProtoMember
CAGs are also used to initialize other attribute types and therefore produce a list of addresses we need to check by hand:
// Find CAGs which are used by other attribute types and shared with ProtoMember
var sharedAtts = vaFieldMapping.Keys
.Select(a => model.CustomAttributeGeneratorsByAddress[a]
.Where(a => a.AttributeType != protoMember))
.SelectMany(l => l);
// Warn about shared mappings
foreach (var item in sharedAtts)
Console.WriteLine($"WARNING: Attribute generator " +
"{item.VirtualAddress.ToAddressString()} is shared with " +
"{item.AttributeType.FullName} - check disassembly listing");
This LINQ voodoo may require some explaining:
First, we iterate over all of the ProtoMember
CAG addresses we selected earlier and call CustomAttributeGeneratorsByAddress[ulong]
with each CAG address as the index. CustomAttributeGeneratoresByAddress
returns a List<CustomAttributeData>
with one entry for each attribute type the CAG at the given address initializes.
We then eliminate ProtoMember
from each of these lists. Since we’re only iterating over CAGs guaranteed to initialize a ProtoMember
, this means that if there are no other attribute types initialized by that CAG, the list will be empty, otherwise it will contain all the other attribute types the CAG initializes.
Now we use SelectMany
to merge all of the lists together and flatten them into a single list. The end result is a list which is the subset of the original ProtoMember
CAG list containing only those CAGs which also initialize other attribute types besides ProtoMember
.
Here is the output:
WARNING: Attribute generator 0x0000000180055270-0x00000001800552C0 is shared with System.ObsoleteAttribute - check disassembly listing
WARNING: Attribute generator 0x0000000180005660-0x00000001800056B0 is shared with System.ObsoleteAttribute - check disassembly listing
WARNING: Attribute generator 0x000000018002FCB0-0x000000018002FD00 is shared with System.ObsoleteAttribute - check disassembly listing
Besides the suspect we already looked at, two more of our CAGs have additional code. When we check them in the disassembler using the same techniques as above, we find that the field number arguments for these three CAGs are 5, 7 and 1 respectively, and we add these modifications as a fixup:
// Fixups we have determined from the disassembly
vaFieldMapping[0x180055270] = 5;
vaFieldMapping[0x180005660] = 7;
vaFieldMapping[0x18002FCB0] = 1;
What is the benefit of writing the above code snippet that checks for “shared” CAGs instead of just going through them all by hand? The answer is that when the game is patched, you will now be able to immediately generate a list of addresses you need to check in the disassembler so that you can modify the fixups quickly.
(see commit for these changes)
Info: The “correct” (or at least more robust) solution to this problem is to use a disassembly framework to scan through each CAG for a call
or jmp
to the ProtoMemberAttribute
constructor, then step backwards one instruction at a time until you find the instruction that sets EDX and read the operand.
I’ll cover this kind of low-level work in a future tutorial – in the meantime, you can see the solution in this commit. This example uses the Capstone disassembly framework (C# bindings) to step through the binary code and determine the attribute values automatically.
Producing the message definitions
Now we have access to everything we need: the types, the names and the field numbers. Let’s get cooking! Here is our overall message output loop:
// This is any field or property with the [ProtoMember] attribute
foreach (var message in messages) {
var name = message.CSharpName;
var fields = message.DeclaredFields.Where(f => f.CustomAttributes.Any(a => a.AttributeType == protoMember));
var props = message.DeclaredProperties.Where(p => p.CustomAttributes.Any(a => a.AttributeType == protoMember));
var proto = new StringBuilder();
proto.Append($"message {name} {{\n");
// TODO: We are going to need to map these C# types to protobuf types!
// Output C# fields
foreach (var field in fields) {
var pmAtt = field.CustomAttributes.First(a => a.AttributeType == protoMember);
proto.Append($" {field.FieldType.Name} {field.Name} = {vaFieldMapping[pmAtt.VirtualAddress.Start]};\n");
}
// Output C# properties
foreach (var prop in props) {
var pmAtt = prop.CustomAttributes.First(a => a.AttributeType == protoMember);
proto.Append($" {prop.PropertyType.Name} {prop.Name} = {vaFieldMapping[pmAtt.VirtualAddress.Start]};\n");
}
proto.Append("}\n");
Console.WriteLine(proto);
}
(see commit for these changes)
This is fairly straightforward C# code which simply iterates over each message class, which in turn iterates all over the fields and properties within each class that have a [ProtoMember]
attribute, printing out their name, type and field number.
Notice how we use our previously constructed vaFieldMapping[pmAtt.VirtualAddress.Start]
to map the virtual address of the [ProtoMember]
CAG on the field or property being output to its corresponding field number.
Here is a snippet of the output for one message:
message ItemPaymentLineDto {
String Type = 1;
String Id = 2;
Int32 Quantity = 3;
}
An excellent start. However, Protobuf uses its own set of primitive types so we’re going to need to convert our .NET type names into Protobuf type names.
Scalar type mappings
This is where things get a bit dicey, because there are several ways to represent both integer and floating point types in Protobuf and we can’t assume how this is implemented. Remember how I said earlier that it would be important to remember that protobuf-net was the exact implementation? This is the reason why. We can look at the documentation or source code of the networking library being used to determine how it serializes specific C# types into Protobuf fields.
For protobuf-net, there is a wonderful Unofficial Manual[2] which among other things lists the complete mapping of C# types to Protobuf types. We add the mapping as a simple lookup table using a Dictionary
:
// Define type map from .NET types to protobuf types
// This is specifically how protobuf-net maps types
// and is not the same for all .NET protobuf libraries
private static Dictionary<string, string> protoTypes = new Dictionary<string, string> {
["System.Int32"] = "int32",
["System.UInt32"] = "uint32",
["System.Byte"] = "uint32",
["System.SByte"] = "int32",
["System.UInt16"] = "uint32",
["System.Int16"] = "int32",
["System.Int64"] = "int64",
["System.UInt64"] = "uint64",
["System.Single"] = "float",
["System.Double"] = "double",
["System.Decimal"] = "bcl.Decimal",
["System.Boolean"] = "bool",
["System.String"] = "string",
["System.Byte[]"] = "bytes",
["System.Char"] = "uint32",
["System.DateTime"] = "google.protobuf.Timestamp"
};
Actually performing the mapping is also straightforward. We just replace the output line with:
protoTypes.TryGetValue(field.FieldType.FullName ?? string.Empty, out var protoTypeName);
proto.Append($" {protoTypeName ?? field.FieldType.Name} {field.Name} = {vaFieldMapping[pmAtt.VirtualAddress.Start]};\n");
We use TryGetValue
to attempt to map the .NET type name to the Protobuf type name. If the type name exists in the dictionary, we use it, otherwise protoTypeName
will be null
so we just fall back to the .NET type name.
A couple of things to note:
- We must use the
FullName
for the field (or property) type we are mapping from because that is how we have stored the type names in the dictionary. We did this on purpose to avoid conflicts with same-named types in other namespaces likeFoo.Double
. - Generic type parameters like
T
have a populatedName
but aFullName
ofnull
in accordance with how the .NET Reflection API functions. To avoid aNullReferenceException
, we just use the null-coalescing operator to pass an empty string as the dictionary key, which will fail and cause the subsequent code to use the .NET type name inName
(eg.T
).
Let’s look at ItemPaymentLineDto
again:
message ItemPaymentLineDto {
string Type = 1;
string Id = 2;
int32 Quantity = 3;
}
Awesome, this is a valid Protobuf message definition!
(see commit for these changes)
Dealing with non-scalar types
We’re on the home stretch now, but there is still work to do. We now look over some messages to see what else needs to be implemented:
message UserSessionHeader {
string UserId = 1;
// unhandled array
String[] Roles = 2;
ContentTargeting ContentTargeting = 3;
String[] IdentityTypes = 4;
string AnalyticsId = 5;
// unhandled map
Dictionary`2[System.String,System.String] ExtraIdentities = 6;
Dictionary`2[System.String,System.String] AdditionalProperties = 7;
}
message OperationHeader {
Guid RequestId = 1; // see note
OperationType Type = 2; // unhandled enum
}
message StoreBundleDto {
string Id = 1;
// unhandled list
List`1[Catapult.Modules.Store.Protocol.Dtos.PaymentDto] PaymentOptions = 2;
StoreBundleRewardDto Reward = 3;
string CategoryName = 4;
// unhandled nullable type
Nullable`1[Int32] Stock = 5;
Nullable`1[DateTime] AvailableTo = 6;
}
Note: There are some classes which receive special help in protobuf-net via the static methods in ProtoBuf.BclHelpers
which provide encodings for TimeSpan
, DateTime
(google.protobuf.TimeStamp
), decimal
and Guid
objects. Therefore we leave these type names intact when producing our .proto
file even though they are non-standard types. If the output is re-compiled with protobuf-net it should work nicely. Changes will need to be made if compiling the output .proto
with a different Protobuf implementation.
It doesn’t really matter what order we take these in so we’ll just go ahead and start with whatever we feel like.
Arrays
The issues with arrays are lexical:
- When a type has a name like
System.UInt32[]
, the array index brackets prevent our type name mapping code from working. We’d like to use Protobuf type names where they can be mapped but leave the rest intact, as we did for primitive type names. - Arrays are represented in Protobuf messages as
repeated
fields, so an array of string is defined asrepeated string
and an array of Foo is defined asrepeated Foo
.
In the .NET Reflection API and Il2CppInspector alike, arrays, pointers and references have an ElementType
property which retrieves the underlying type, so we simply use this as the input to the type map when handling an array:
// Handle arrays
var typeBaseName = type.IsArray? type.ElementType.FullName : type.FullName ?? string.Empty;
// Handle primitive value types
if (protoTypes.TryGetValue(typeBaseName, out var protoTypeName))
typeBaseName = protoTypeName;
else
typeBaseName = type.IsArray? type.ElementType.Name : type.Name;
// Handle arrays
var annotatedName = typeBaseName;
if (type.IsArray)
annotatedName = "repeated " + annotatedName;
// Output field
proto.Append($" {annotatedName} {name} = {vaFieldMapping[pmAtt.VirtualAddress.Start]};\n");
We simply replace uses of FullName
with ElementType.FullName
and Name
with ElementType.Name
when type.IsArray
is true. Before outputting the field, we prepend the repeated
keyword to the type name if the type is an array.
(see commit for these changes)
Lists
As far as Protobuf is concerned, a .NET List
or any other kind of one-dimensional sequence of elements is just an array. The only difference is which container the developer has chosen to represent the sequence in their code. On the wire, they are all the same.
First we need to identify if the field we are examining is indeed a list. There are numerous possibilities:
type.Namespace == "System.Collections.Generic" && type.UnmangledBaseName == "List"
or:
type.FullName == "System.Collections.Generic.List`1"
However there is a better way:
type.ImplementedInterfaces.Any(i => i.FullName == "System.Collections.Generic.IList`1"))
This query returns true if the type implements IList<T>
, which means rather then just catching List<T>
, this check will also pick up ArrayList
, SynchronizedCollection<T>
, ImmutableList<T>
, ReadOnlyCollection<T>
plus all of the other one-dimensional collections in .NET, as well as any custom collections defined by the developers in the application being examined that implement IList<T>
. A much more robust test!
Tip: TypeInfo.ImplementedInterfaces
returns all of the interfaces implemented by a type including those inherited from its base classes. To get only the interfaces implemented explicitly by a specific derived type, use TypeInfo.NonInheritedInterfaces
.
Here is the full solution:
var isRepeated = type.IsArray;
var typeFullName = isRepeated? type.ElementType.FullName : type.FullName ?? string.Empty;
var typeFriendlyName = isRepeated? type.ElementType.Name : type.Name;
// Handle one-dimensional collections like lists
if (type.ImplementedInterfaces.Any(i => i.FullName == "System.Collections.Generic.IList`1")) {
typeFullName = type.GenericTypeArguments[0].FullName;
typeFriendlyName = type.GenericTypeArguments[0].Name;
isRepeated = true;
}
// Handle primitive value types
if (protoTypes.TryGetValue(typeFullName, out var protoTypeName))
typeFriendlyName = protoTypeName;
// Handle repeated fields
var annotatedName = typeFriendlyName;
if (isRepeated)
annotatedName = "repeated " + annotatedName;
We merge the handling of arrays and lists by creating a flag isRepeated
which we use to track whether we’re dealing with an array/list or a regular type.
We also split up the name handling into two variables, typeFullName
– which is the name we will eventually look up in the type map – and typeFriendlyName
, which is the name that will be output. The typeFriendlyName
is initially set to the unqualified .NET type name and overwritten if the type name can be found in the Protobuf primitive type map.
To get the underlying type of items in the list, we access the first element of TypeInfo.GenericTypeArguments
array property, which returns all of the type arguments used to instantiate the generic type. In this case, there is just one type argument: the type of items in the list.
Note: This is a naive implementation which doesn’t handle nesting of lists or arrays in lists.
(see commit for these changes)
Dictionaries
Dictionaries are .NET’s basic flavour of associative arrays where a set of unique keys each indexes one value. Protobuf 3 encodes these as a map<key, value>
field. Protobuf 2 does not support this but Google offers a simple workaround which you can implement if needed.
// Handle maps (IDictionary)
if (type.ImplementedInterfaces
.Any(i => i.FullName == "System.Collections.Generic.IDictionary`2")) {
// This time we have two generic arguments to deal with - the key and the value
var keyFullName = type.GenericTypeArguments[0].FullName;
var valueFullName = type.GenericTypeArguments[1].FullName;
// We're going to have to deal with building this proto type name
// separately from the value types below
// We don't set isRepeated because it's implied by using a map type
protoTypes.TryGetValue(keyFullName, out var keyFriendlyName);
protoTypes.TryGetValue(valueFullName, out var valueFriendlyName);
typeFriendlyName = $"map<{keyFriendlyName ?? type.GenericTypeArguments[0].Name}" +
$", {valueFriendlyName ?? type.GenericTypeArguments[1].Name}>";
}
Handling dictionaries is much like handling lists: we detect a dictionary by checking if the type implements IDictionary<K, V>
, but this time we have two type arguments and we run them both through the Protobuf type map before building a single final type name in typeFriendlyName
.
As we are outputting a single map
and not multiple maps, we don’t set isRepeated
to true for dictionaries.
(see commit for these changes)
Nullable types
In C#, types like int?
are really just syntactic sugar for the generic wrapper Nullable<T>
– in this case Nullable<int>
. So once again we will pluck the true underlying type out from t he first generic type argument.
Secondly, according to the Protobuf-net Unofficial Manual[2], nullable types with a value of null are not sent over the wire, so this is a big clue that they should use Protobuf’s optional
keyword, which signifies that a particular field may or may not be present in the message.
The solution is therefore straightforward:
var isOptional = false;
// ...
// Handle nullable types
if (type.FullName == "System.Nullable`1") {
// Once again we look at the first generic argument to get the real type
typeFullName = type.GenericTypeArguments[0].FullName;
typeFriendlyName = type.GenericTypeArguments[0].Name;
isOptional = true;
}
// ...
// Handle nullable (optional) fields
if (isOptional)
annotatedName = "optional " + annotatedName;
This follows identical principles as working with an IList<T>
, except here we check FullName
for a specific single type, and add an isOptional
flag so that we can output the optional
keyword later.
(see commit for these changes)
Enumerations
Enumerations pose a different problem to the previous issues. We don’t actually need to change the individual field output at all, but we need to ensure that any enumerations used by any messages also have their type definitions output to the .proto
file.
There are various ways to tackle this. The approach I’ve chosen is to keep a running list of all the used enums as each message is constructed. Once we have processed every message, we have a complete list of required enums and can then output their type definitions.
First we build the list:
var enums = new HashSet<TypeInfo>();
foreach (var message in messages) {
// ...
foreach (var field in fields) {
// ...
if (field.FieldType.IsEnum)
enums.Add(field.FieldType);
}
foreach (var prop in props) {
// ...
if (prop.PropertyType.IsEnum)
enums.Add(prop.PropertyType);
}
}
Here we use a HashSet
to guarantee each item in the list is unique. We only want to output each enum once, and HashSet.Add()
will conveniently discard any items already in the list without throwing an exception.
Tip: TypeModel
guarantees that there will only ever be one instance of each type definition (TypeInfo
) regardless of how you acquire it. This is why the default equality comparer in HashSet<T>
knows when two enum types are the same. This is also why many of the previous LINQ queries work as the default equality operator ==
for objects relies on reference equality.
Outputting the enum definitions requires us to use the GetEnumNames()
and GetEnumValues()
methods and some more LINQ sorcery:
// Output enums
var enumText = new StringBuilder();
foreach (var e in enums) {
enumText.Append("enum " + e.Name + " {\n");
var namesAndValues = e.GetEnumNames().Zip(e.GetEnumValues().Cast<int>(), (n, v) => n + " = " + v);
foreach (var nv in namesAndValues)
enumText.Append(" " + nv + ";\n");
enumText.Append("}\n\n");
}
The LINQ Zip
function has this playful little signature:
IEnumerable<TResult> IEnumerable<TFirst>.Zip(IEnumerable<TSecond>, Func<TFirst, TSecond, TResult>)
Essentially, it takes two lists and allows you to apply a combinator function to each pair of elements in order and return a single list as the result. GetEnumNames()
and GetEnumValues()
return their lists in an order such that the first name will correspond to the first value and so on, so here we take each pair of elements and use a lambda function to merge each one into a simple string: name = value
, which we then combine into a single string in the subsequent foreach
block.
Note: TypeInfo.GetEnumValues()
returns an untyped Array
of objects. This is because .NET allows enums to be derived from types other than the default of int
. For Protobuf, we assume (wait, I said you should never assume anything… oh well, this one is safe… you can trust me on this) that all enums are simple integers so we must use the IEnumerable.Cast<T>()
extension method to cast the enum values to int
.
(see commit for these changes)
Word to your Moms, I Came to Drop Bombs
Time to sit back and admire our work. Let’s check those three messages from above once more:
enum OperationType {
OneWay = 1;
Request = 2;
Reply = 3;
}
message UserSessionHeader {
string UserId = 1;
repeated string Roles = 2;
ContentTargeting ContentTargeting = 3;
repeated string IdentityTypes = 4;
string AnalyticsId = 5;
map<string, string> ExtraIdentities = 6;
map<string, string> AdditionalProperties = 7;
}
message OperationHeader {
Guid RequestId = 1;
OperationType Type = 2;
}
message StoreBundleDto {
string Id = 1;
repeated PaymentDto PaymentOptions = 2;
StoreBundleRewardDto Reward = 3;
string CategoryName = 4;
optional int32 Stock = 5;
optional google.protobuf.Timestamp AvailableTo = 6;
}
Perfect! If you spent 3 hours on this as I did (and another 15 hours writing the tutorial), and are egocentric passionate about coding as I am, you should be feeling pretty pleased with yourself right now. You’ve turned a jumble of random-looking bytes into a readable set of network messages which you can re-use. GG!
The game is not quite over yet, however. There are some additional steps you will probably want to perform:
- Put all the output in a
StringBuilder
and write it to a file. - Tidy up and comment your code. You will come back to it in 6 months, and you won’t remember how it works – so keep it clean!
- Download protobuf-net, run the generated
.proto
file through it and check that it compiles correctly.
These steps are obviously all incredibly difficult and well beyond the scope of such a simple guide like this 🙂 I’m pretty sure you can handle it, though.
Follow-up work
Just because the .proto
compiles doesn’t mean that it’s actually correct. Monitor the network traffic by either disabling TLS in the client and creating a tunnel them monitoring the traffic flow between the client and tunnel ingest, or by hooking the packet send/receive functions in the client and dumping the output to a file for analysis. Tools like Wireshark Protobuf Dissector are handy for this.
We haven’t implemented all features of Protobuf that may be used by the application, including extensions and nested types among others. You will need to add support for these if the application you’re analyzing uses them.
In protobuf-net, the [ProtoContract]
attribute can take many arguments which may affect how messages are serialized. We have completely ignored this in this article. The small number of CAGs I looked at only set the name of the message, which doesn’t matter for packet analysis. You can use the same techniques discussed earlier to fetch all the [ProtoMember]
CAGs to check out the CAGs for [ProtoContract]
in your disassembler.
We could also solve the “shared CAG problem” by using a disassembly framework in conjunction with Il2CppInspector to step through the code and discover the desired constructor arguments.
All four of these topics are rather complicated and merit separate articles in and of themselves. The work of an enthusiastic hacker is never done!
Conclusion
Don’t reinvent the wheel. Il2CppInspector does 90% of the work for you in dissecting the IL2CPP application and modelling it as a series of APIs. The tutorial above can be applied to various file formats, any version of IL2CPP and any supported target processor architecture using the same code. When the application is updated, once again no changes are required (except for our CAG fixups, which again can be automated). Essentially, you require almost no knowledge of the binary contents or layout at all. This is a huge boon to productivity and allows you to better automate your toolchain while working on your reverse engineering projects.
I hope you found this tutorial a useful step on your way to becoming a hacker warlord. Happy hacking!
RaaS – Ranting as a Service
I play video games badly and complain about the state of the video games industry regularly on my livestream: https://trovo.live/djkaty.
I don’t usually code or taking coding questions on the stream so please keep those to the comments below, but if you like games and banter I’d love to meet you! (Warning: very frequent use of mature language and mature themes)
References
[1] Git Submodules in Visual Studio by Jonas Rapp
[2] Protobuf-net: The Unofficial Manual on Loyc
[3] Protocol Buffers Language Guide (Proto3) on Google Developers
[4] x64 Calling Convention on Microsoft Docs
Fantastic! Thanks alot for the article and the sources. I’m trying to do the same but on a Android game, but i still couldnt manage to find the Field numbers. Do you have any tips to find it on arm64? Thanks again! ❤
Is there any way to use the Il2CppInspector.Il2CppInspector.LoadFromFile() with a single APK file? When I use the GUI I can export the .cs file, but I try to automate it as you described in this blog post