Skip to content

Snippets

vfsfitvnm edited this page May 15, 2023 · 7 revisions

Initialization

import "frida-il2cpp-bridge";

Il2Cpp.perform(() => {
    // code here
    console.log(Il2Cpp.unityVersion);
});

You import the global Il2Cpp object in the following way.
Before executing any Il2Cpp operation, the caller thread should be attached to the application domain; after the execution, it should be detached. I said "should" because it's not mandatory, however you can bump into some abort or access violation errors if you skip this step.
You can ensure this behavior wrapping your code inside a Il2Cpp.perform function - this wrapper also ensures any initialization process has finished. Given so, this function is asynchronous because it may need to wait for Il2Cpp module to load and initialize (il2cpp_init).

Dumping

import "frida-il2cpp-bridge";

Il2Cpp.perform(() => {
    // it will use default directory path and file name: /<default_path>/<default_name>.cs
    Il2Cpp.dump();

    // the file name is overridden: /<default_path>/custom_file_name.cs
    Il2Cpp.dump("custom_file_name");

    // the file name and directory path are overridden: /i/can/write/to/this/path/custom_file_name.cs
    Il2Cpp.dump("custom_file_name", "/i/can/write/to/this/path");
});

This operation may require a directory path (e.g. a place where the application can write to) and a file name. If not provided, the code will just guess them; however it's not guaranteed to succeed.

class Mono.DataConverter.PackContext : System.Object
{
    System.Byte[] buffer; // 0x10
    System.Int32 next; // 0x18
    System.String description; // 0x20
    System.Int32 i; // 0x28
    Mono.DataConverter conv; // 0x30
    System.Int32 repeat; // 0x38
    System.Int32 align; // 0x3c

    System.Void Add(System.Byte[] group); // 0x012ef4f0
    System.Byte[] Get(); // 0x012ef6ec
    System.Void .ctor(); // 0x012ef78c
}

Tracing

import "frida-il2cpp-bridge";

Il2Cpp.perform(() => {
    const SystemString = Il2Cpp.corlib.class("System.String");

    // it traces method calls and returns
    Il2Cpp.trace()
        .classes(SystemString)
        .filterMethods(method => method.isStatic && method.returnType.equals(SystemString.type) && !method.isExternal)
        .and()
        .attach();

    // detailed trace, it traces method calls and returns and it reports every parameter
    Il2Cpp.trace(true)
        .assemblies(Il2Cpp.corlib.assembly)
        .filterClasses(klass => klass.namespace == "System")
        .filterParameters(param => param.type.equals(SystemString) && param.name == "msg")
        .and()
        .assemblies(Il2Cpp.corlib.assembly)
        .filterMethods(method => method.name.toLowerCase().includes("begin"))
        .and()
        .attach();
});

Il2Cpp.Tracer follows the builder pattern in order to be flexible and, you know, this pattern is better than a custom domain specific language. A new builder must be created via Il2Cpp.trace(). After that, you can start searching in the whole domain or in a set of assemblies, classes or methods. Then, you can apply a custom filter via filter*. You push all the methods that meet the requirements in a private field using and. In this way, you can combine multiple requirements without writing complex filters (see the third example).
After and you can start over or you can start the actual tracing calling attach.

Uh, you don't need al this black magic? Do you just want to trace a single method?

import "frida-il2cpp-bridge";

Il2Cpp.perform(() => {
    const Equals = Il2Cpp.corlib.class("System.String").method("Equals");

    Il2Cpp.trace().methods(Equals).and().attach();

    // I know, this is verbose
});

There are three already defined strategies you can follow in order to trace methods.

  • Il2Cpp.trace(false):

    0x01a2f3e4 ┌─System.String::Concat
    0x01a3cfbc │ ┌─System.String::FastAllocateString
    0x01a3cfbc │ └─System.String::FastAllocateString
    0x01a3f118 │ ┌─System.String::FillStringChecked
    0x01a3f118 │ └─System.String::FillStringChecked
    0x01a3f118 │ ┌─System.String::FillStringChecked
    0x01a3f118 │ └─System.String::FillStringChecked
    0x01a2f3e4 └─System.String::Concat
    
    0x01a41f60 ┌─System.String::Replace
    0x01a4346c │ ┌─System.String::IndexOfUnchecked
    0x01a4346c │ └─System.String::IndexOfUnchecked
    0x01a3cfbc │ ┌─System.String::FastAllocateString
    0x01a3cfbc │ └─System.String::FastAllocateString
    0x01a41f60 └─System.String::Replace
    
  • Il2Cpp.trace(true) (a LOT slower then false!):

    0x01a2f3e4 ┌─System.String::Concat(str0 = "Creating AndroidJavaClass from ", str1 = "com.unity3d.player.UnityPlayer");
    0x01a3cfbc │ ┌─System.String::FastAllocateString(length = 61);
    0x01a3cfbc │ └─System.String::FastAllocateString = "Creating AndroidJavaClass from com.unity3d.player.UnityPlayer";
    0x01a3f118 │ ┌─System.String::FillStringChecked(dest = "Creating AndroidJavaClass from com.unity3d.player.UnityPlayer", destPos = 0, src = "Creating AndroidJavaClass from ");
    0x01a3f118 │ └─System.String::FillStringChecked;
    0x01a3f118 │ ┌─System.String::FillStringChecked(dest = "Creating AndroidJavaClass from com.unity3d.player.UnityPlayer", destPos = 31, src = "com.unity3d.player.UnityPlayer");
    0x01a3f118 │ └─System.String::FillStringChecked;
    0x01a2f3e4 └─System.String::Concat = "Creating AndroidJavaClass from com.unity3d.player.UnityPlayer";
    
    0x01a41f60 ┌─System.String::Replace(this = com.unity3d.player.UnityPlayer, oldChar = 46, newChar = 47);
    0x01a4346c │ ┌─System.String::IndexOfUnchecked(this = com.unity3d.player.UnityPlayer, value = 46, startIndex = 0, count = 30);
    0x01a4346c │ └─System.String::IndexOfUnchecked = 3;
    0x01a3cfbc │ ┌─System.String::FastAllocateString(length = 30);
    0x01a3cfbc │ └─System.String::FastAllocateString = "com/unity3d/player/UnityPlayer";
    0x01a41f60 └─System.String::Replace = "com/unity3d/player/UnityPlayer";
    

The output is nicely coloured so you won't get crazy when inspecting the console.

Heap scanning

import "frida-il2cpp-bridge";

Il2Cpp.perform(() => {
    const mscorlib = Il2Cpp.domain.assembly("mscorlib").image;
    const SystemType = mscorlib.class("System.Type");

    // +++ heap scanning using class descriptors +++
    Il2Cpp.gc.choose(SystemType).forEach((instance: Il2Cpp.Object) => {
        // instance.class.type.name == "System.Type"
    });
    // --- heap scanning using class descriptors ---

    // +++ heap scanning using a memory snapshot +++
    const snapshot = Il2Cpp.MemorySnapshot.capture();
    snapshot.objects.filter(Il2Cpp.isExactly(SystemType)).forEach((instance: Il2Cpp.Object) => {
        // instance.class.type.name == "System.Type"
    });
    snapshot.free();
    // --- heap scanning using a memory snapshot ---
});

You can "scan" the heap or whatever the place where the objects get allocated in to find instances of the given class. There are two ways of doing this: reading classes GC descriptors or taking a memory snapshot. However, I don't really know how they internally work, I read enough uncommented C++ source code for my taste.

Generics handling

Dealing with generics is problematic when the global-metadata.dat file is ignored. You can gather the inflated version (if any) via Il2Cpp.Class.inflate and Il2Cpp.Method.inflate. Reference types (aka objects) all shares the same code: it is easy to retrieve virtual address in this case. Value types (aka primitives and structs) does not share any code. inflate will always return an inflated class or method (you must match the number of type arguments with the number of types you pass to inflate), but the returned value it's not necessarily a class or method that has been implemented.

import "frida-il2cpp-bridge";

Il2Cpp.perform(() => {
    const SystemObject = Il2Cpp.corlib.class("System.Object");
    const SystemInt32 = Il2Cpp.corlib.class("System.Int32");

    // +++ inflating a generic class +++
    const GenericList = Il2Cpp.corlib.class("System.Collections.Generic.List<T>");

    // This class is shared among all reference types
    const SystemObjectList = GenericList.inflate(SystemObject);

    // This class is specific to System.Int32, because it's a value type
    const SystemInt32List = GenericList.inflate(SystemInt32);
    // --- inflating a generic class ---

    // +++ inflating a generic method +++
    const FindAll = Il2Cpp.corlib.class("System.Array").method("FindAll");
    // FindAll is a generic method, its virtual address is null

    // This is the FindAll for every reference type
    const SystemObjectFindAll = FindAll.inflate(SystemObject);

    // This is the FindAll specific to System.Int32, because it's a value type
    const SystemInt32FindAll = FindAll.inflate(SystemInt32);
    // --- inflating a generic method ---
});

Methods

import "frida-il2cpp-bridge";

Il2Cpp.perform(() => {
    const mscorlib = Il2Cpp.domain.assembly("mscorlib").image;
    const SystemString = mscorlib.class("System.String");

    const IsNullOrEmpty = mscorlib.class("System.String").method<boolean>("IsNullOrEmpty");
    const MemberwiseClone = mscorlib.class("System.Object").method("MemberwiseClone");

    const string = Il2Cpp.string("Hello, il2cpp!");

    // static method invocation, it will return false
    const result0 = IsNullOrEmpty.invoke(string);

    // instance method invocation, it will return true
    const result1 = string.object.method<boolean>("Contains").invoke(Il2Cpp.string("il2cpp"));

    //
    IsNullOrEmpty.implementation = function (value: Il2Cpp.String): boolean {
        value.content = "!"; // <--- onEnter
        // <--- onEnter
        const result = this.method<boolean>("IsNullOrEmpty").invoke(value);
        // <--- onLeave
        console.log(result); // <--- onLeave
        return !!result; // <--- onLeave
    };

    //
    MemberwiseClone.implementation = function (): Il2Cpp.Object {
        // `this` is a "System.Object", because MemberwiseClone is a System.Object method

        // `originalInstance` can be any type
        const originalInstance = new Il2Cpp.Object(this.handle);

        // not cloning!
        return this as Il2Cpp.Object;
    };
});
  • Invocation

    You can invoke any method using invoke (this is just an abstraction over NativeFunction).

  • Replacement & Interception

    You can replace and intercept any method implementation using implementation (this is just an abstraction over Interceptor.replace and NativeCallback). If the method is static, this will be a Il2Cpp.Class, or Il2Cpp.Object otherwise: the instance is artificially down-casted to the method declaring class.
    Some other examples:

    // System.Int32 GetByteCount(System.Char[] chars, System.Int32 index, System.Int32 count, System.Boolean flush);
    GetByteCount.implementation = function (chars: Il2Cpp.Array<number>, index: number, count: number, flush: boolean): number {};
    
    // System.Boolean InternalFallback(System.Char ch, System.Char*& chars);
    InternalFallback.implementation = function (ch: number, chars: Il2Cpp.Reference<Il2Cpp.Pointer<number>>): boolean {};
  • Overloading

    You can get the overload of any method specifying the parameter type names:

    // mscorlib.dll
    class Locale : System.Object
    {
        static System.String GetText(System.String msg); // 0x00a281b0
        static System.String GetText(System.String fmt, System.Object[] args); // 0x00a281c0
    }
    const Locale = Il2Cpp.corlib.class("Locale");
    const GetText = Locale.method("GetText"); // <--- 0x00a281b0
    const GetText2 = GetText.overload("System.String", "System.Object[]"); // <--- 0x00a281c0
Clone this wiki locally