Home > IL2CPP > Practical IL2CPP Reverse Engineering: Extracting Protobuf definitions from applications using protobuf-net (Case Study: Fall Guys)

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.

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:

  1. 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)
  2. 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’s RuntimeTypeModel 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.

  1. 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)
  2. 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, use git submodule add instead[1])
  3. Right-click on the solution in Solution Explorer and choose Add -> New Solution Folder. Call it Submodules.
  4. Right-click Submodules and choose Add -> Existing Project… then navigate through your solution folder and select Il2CppInspector/Il2CppInspector.Common/Il2CppInspector.csproj. Il2CppInspector will now appear in Solution Explorer.
  5. Repeat this process and this time select Il2CppInspector/Bin2Object/Bin2Object/Bin2Object.csproj.
  6. 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).
  7. To confirm that it works, add:
    using Il2CppInspector;
    to the top of Program.cs in your console app project and compile.

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 Il2CppInspectors – 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 applied

  • Name – the unscoped CTS name of the type with decorators applied. This is what you will use most often along with FullName

  • 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 removed

  • CSharpName – 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. if BaseName is List`1, then UnmangledBaseName is List.

  • 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 as CSharpName 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. If includeVariance 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) – like CSharpName 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 in usingScope.

(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 ulongs 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.

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 like Foo.Double.
  • Generic type parameters like T have a populated Name but a FullName of null in accordance with how the .NET Reflection API functions. To avoid a NullReferenceException, 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 in Name (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:

  1. 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.
  2. Arrays are represented in Protobuf messages as repeated fields, so an array of string is defined as repeated string and an array of Foo is defined as repeated 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

Categories: IL2CPP Tags:
  1. No comments yet.
  1. No trackbacks yet.

Share your thoughts! Note: to post source code, enclose it in [code lang=...] [/code] tags. Valid values for 'lang' are cpp, csharp, xml, javascript, php etc. To post compiler errors or other text that is best read monospaced, use 'text' as the value for lang.

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.

%d bloggers like this: