Skip to content

Snippets

vfsfitvnm edited this page Feb 9, 2022 · 7 revisions

Initialization

import "frida-il2cpp-bridge";

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

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()
        .classes() // or methods
        .build();

    // the file name is overridden: /<default_path>/custom_file_name.cs
    Il2Cpp.dump()
        .fileName("custom_file_name")
        .classes() // or methods
        .build();

    // the file name and directory path are overridden: /i/can/write/to/this/path/custom_file_name.cs
    Il2Cpp.dump()
        .directoryPath("/i/can/write/to/this/path")
        .fileName("custom_file_name")
        .classes() // or methods
        .build()
});

Ok, this is probably exaggerated, but I liked the new Il2Cpp.Tracer implementation so I wanted to apply it to Il2Cpp.Dumper too.

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.

There are two possible configurations.

  • classes will diplay every class like the following:

    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
    }
  • methods will produce a input for static analysis tools:

    0x012ef4f0 Mono.DataConverter.PackContext.Add
    0x012ef6ec Mono.DataConverter.PackContext.Get
    0x012ef78c Mono.DataConverter.PackContext..ctor
    

    For more information, see ghidra script.

Tracing

import "frida-il2cpp-bridge";

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

    // it traces method calls
    Il2Cpp.trace()
        .assemblies(Il2Cpp.Image.corlib.assembly, Il2Cpp.Domain.assembly("System"))
        .filterMethods(method => method.isStatic && method.returnType.equals(SystemString.type) && !method.isExternal)
        .commit()
        .simple()
        .build();

    // it traces method calls and returns
    Il2Cpp.trace()
        .classes(SystemString)
        .commit()
        .full()
        .build();

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

    // custom behaviour
    Il2Cpp.trace()
        .domain()
        .filterMethods(method => method.parameterCount == 0)
        .commit()
        .special((target: Il2Cpp.Method) => {
            const signature = `${target.name} (${target.parameterCount})`;
            return {
                onLeave(returnValue: Il2Cpp.Method.ReturnType) {
                    console.log(`[custom log] ${signature} ----> ${returnValue}`);
                }
            };
        })
        .build();
});

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 commit. In this way, you can combine multiple requirements without writing complex filters (see the third example).
After commit you can start over or you can choose a generator (simple, full or detailed). A generator is just a function that produces the callbacks that will be called when a method is invoked. You can roll your own generator using special (see the last example). Finally, you begin the actual tracing calling build.

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.Image.corlib.class("System.String").method("Equals");

    Il2Cpp.trace()
        .methods(Equals)
        .commit()
        .full()
        .build();

    // I know, this is verbose
});

There are three already defined strategies you can follow in order to trace methods. I will use onEnter and onLeave words, however Il2Cpp.Tracer does not use Interceptor.attach, but a combination of Interceptor.replace and NativeFunction (here's why).

  • simple only reports onEnter calls.

    [il2cpp] 0x01a2f3e4 System.String.Concat
    [il2cpp] 0x01a3cfbc System.String.FastAllocateString
    [il2cpp] 0x01a3f118 System.String.FillStringChecked
    [il2cpp] 0x01a3f118 System.String.FillStringChecked
    
    [il2cpp] 0x01a41f60 System.String.Replace
    [il2cpp] 0x01a4346c System.String.IndexOfUnchecked
    [il2cpp] 0x01a3cfbc System.String.FastAllocateString
    
  • full reports both onEnter and onLeave nicely.

    [il2cpp] 0x01a2f3e4 ┌─System.String.Concat
    [il2cpp] 0x01a3cfbc │ ┌─System.String.FastAllocateString
    [il2cpp] 0x01a3cfbc │ └─System.String.FastAllocateString
    [il2cpp] 0x01a3f118 │ ┌─System.String.FillStringChecked
    [il2cpp] 0x01a3f118 │ └─System.String.FillStringChecked
    [il2cpp] 0x01a3f118 │ ┌─System.String.FillStringChecked
    [il2cpp] 0x01a3f118 │ └─System.String.FillStringChecked
    [il2cpp] 0x01a2f3e4 └─System.String.Concat
    
    [il2cpp] 0x01a41f60 ┌─System.String.Replace
    [il2cpp] 0x01a4346c │ ┌─System.String.IndexOfUnchecked
    [il2cpp] 0x01a4346c │ └─System.String.IndexOfUnchecked
    [il2cpp] 0x01a3cfbc │ ┌─System.String.FastAllocateString
    [il2cpp] 0x01a3cfbc │ └─System.String.FastAllocateString
    [il2cpp] 0x01a41f60 └─System.String.Replace
    
  • detailed reports both onEnter and onLeave nicely, plus every printable value.

    [il2cpp] 0x01a2f3e4 ┌─System.String.Concat(System.String str0 = "Creating AndroidJavaClass from ", System.String str1 = "com.unity3d.player.UnityPlayer")
    [il2cpp] 0x01a3cfbc │ ┌─System.String.FastAllocateString(System.Int32 length = 61)
    [il2cpp] 0x01a3cfbc │ └─System.String.FastAllocateString System.String = "Creating AndroidJavaClass from com.unity3d.player.UnityPlayer"
    [il2cpp] 0x01a3f118 │ ┌─System.String.FillStringChecked(System.String dest = "Creating AndroidJavaClass from com.unity3d.player.UnityPlayer", System.Int32 destPos = 0, System.String src = "Creating AndroidJavaClass from ")
    [il2cpp] 0x01a3f118 │ └─System.String.FillStringChecked System.Void = undefined
    [il2cpp] 0x01a3f118 │ ┌─System.String.FillStringChecked(System.String dest = "Creating AndroidJavaClass from com.unity3d.player.UnityPlayer", System.Int32 destPos = 31, System.String src = "com.unity3d.player.UnityPlayer")
    [il2cpp] 0x01a3f118 │ └─System.String.FillStringChecked System.Void = undefined
    [il2cpp] 0x01a2f3e4 └─System.String.Concat System.String = "Creating AndroidJavaClass from com.unity3d.player.UnityPlayer"
    
    [il2cpp] 0x01a41f60 ┌─System.String.Replace(this = com.unity3d.player.UnityPlayer, System.Char oldChar = 46, System.Char newChar = 47)
    [il2cpp] 0x01a4346c │ ┌─System.String.IndexOfUnchecked(this = com.unity3d.player.UnityPlayer, System.Char value = 46, System.Int32 startIndex = 0, System.Int32 count = 30)
    [il2cpp] 0x01a4346c │ └─System.String.IndexOfUnchecked System.Int32 = 3
    [il2cpp] 0x01a3cfbc │ ┌─System.String.FastAllocateString(System.Int32 length = 30)
    [il2cpp] 0x01a3cfbc │ └─System.String.FastAllocateString System.String = "com/unity3d/player/UnityPlayer"
    [il2cpp] 0x01a41f60 └─System.String.Replace System.String = "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.Filtering.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.Image.corlib.class("System.Object");
    const SystemInt32 = Il2Cpp.Image.corlib.class("System.Int32");


    // +++ inflating a generic class +++
    const GenericList = Il2Cpp.Image.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.Image.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.from("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.from("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 {}

Clone this wiki locally