11 min read

Dump IL2CPP với JavaScript

cover
Table of Contents

[!] Bài viết này chỉ là trải nghiệm của mình khi reverse, không mang tính chất một bài hướng dẫn.

Story

Là một thằng wibu người đam mê văn hoá Nhật Bản, lướt Facebook thấy một cái video Raiden Shogun rút kiếm vào tháng 6/2022, mình đã chính thức bước chân vào con đường gacha nghiện ngập.

Qua vài tháng cày game khá ải chỉa, mình có thấy một repo trên GitHub là Akebi - Một cheat mã nguồn mở, từ đó bước chân vào con đường tà đạo 😈

Nhưng khi đó mình thật sự không quan tâm tới nó lắm, vì lúc đó mình chưa nghiên cứu ba cái này bao giờ (chính xác hơn là chưa hack game Unity bao giờ), nên chỉ sử dụng chứ không quan tâm tới cách nó hoạt động ra sao.

miHoYo (nay là HoYoverse) hay Michos, nổi tiếng với việc sản xuất và phát hành các series game mang đồ hoạ anime với lượng player đông đảo (bởi được thành lập bởi 3 ông wibu mà :D). Trong đó không thể không nhắc tới Genshin Impact a.k.a Genshit, G*yshit

Genshin Impact là trò chơi hành động nhập vai do miHoYo phát triển, được phát hành lần đầu vào ngày 28 tháng 9 năm 2020.

Là “con gà đẻ trứng vàng” của Michos, Genshin Impact được đầu tư khá kĩ càng để chống lại các hình thức leak và cheat. Từ việc cho triển khai hàng loạt biện pháp tăng cường bảo mật game, cho đến việc kiện các leaker hay những cá nhân phát hành cheat.

Nhưng mà…chặn thế nào được :)

Ngâm cứu

Trước hết hãy tìm hiểu IL2CPP nó là cái gì và tại sao Genshin lại khó dump đến thế.

IL2CPP là gì?

IL2CPP (Intermediate Language to C++) là một framework trong Unity, chuyển đổi code IL (Intermediate Language) của C# thành code C++ và sau đó compile thành binary code. Điều này giúp tăng hiệu suất và bảo mật cho ứng dụng, đặc biệt trên các nền tảng như iOS, Android, và WebGL.

Và vì nó convert code từ C# sang C++ nên sẽ reverse khó hơn rất nhiều so với bình thường.

Cấu trúc thư mục

Cấu trúc của một game IL2CPP thông thường trên Windows sẽ như sau:

├── *.exe                         # Tệp thực thi của game (Windows)
├── *_Data/                       # Thư mục chứa dữ liệu của game
│   ├── Resources/                # Chứa các tài nguyên game
│   ├── StreamingAssets/          # Các tài nguyên stream runtime
│   ├── globalgamemanagers        # Tệp quản lý tài nguyên của game
│   ├── globalgamemanagers.assets # Dữ liệu tài nguyên game
│   ├── sharedassets*             # Chứa tài nguyên chung
│   ├── resources.assets          # Chứa tài nguyên chính
│   ├── resources.assets.resS     # Bổ sung cho resources.assets
│   ├── level*                    # Dữ liệu của các level trong game
│   ├── il2cpp_data/              # Chứa data runtime của IL2CPP
│   │   ├── Metadata/             # Metadata của game
│   │   ├── Resources/            # Chứa resources runtime cho IL2CPP
│   │   └── etc/                  # Thư mục chứa các tệp hỗ trợ IL2CPP
│   └── Plugins/                  # Các plugin bên ngoài (nếu có)
├── UnityPlayer.dll               # Thư viện Unity cần để chạy game
├── GameAssembly.dll              # Tệp chứa mã C++ đã biên dịch từ IL

Để dump một game IL2CPP thông thường thì có thể sử dụng Il2CppDumper

Il2CppDumper.exe <executable-file> <global-metadata> <output-directory>

Trong đó executable-file chính là GameAssembly.dll, global-metadataglobal-metadata.dat.

Hoặc có thể sử dụng Il2CppInspector.

P/s: Tác giả của Il2CppInspector có một loạt blog rất hay, viết về cách IL2CPP hoạt động và các trick khác nhau mà các dev game sử dụng để ẩn metadata khỏi bị reverse.

Nhưng với game Michos thì khác. Genshin được build bằng Unity version 2017.4.30f1, và bản Unity này đã được Michos modify khá nặng để sử dụng trong nội bộ.

Đối với các phiên bản cũ (trước phiên bản 3.2), bạn có thể dump Genshin bằng một phiên bản Il2CppDumper đã được modify hoặc sử dụng Metadata để decrypt và convert về định dạng metadata unity rồi sử dụng với Il2CppDumper gốc.

Đó là với thời xưa cũ, còn bây giờ thì việc đó là không thể.

Metadata không chỉ 1 mà là 2 file một lượt luôn

Kể từ phiên bản 4.8, UserAssembly.dllUnityPlayer.dll cũng không còn nữa, thay vào đó nó được compile trực tiếp vào file exe game.

Vậy thì dump như thế nào?

Từng có một method khác là sử dụng Zygisk để dump runtime: Zygisk-Il2CppDumper.

Và một bản fork của nó hoạt động dưới dạng một file DLL cho Windows Il2CppRuntimeDumper.

Nhưng từ phiên bản 3.2, Michos đã ẩn hết các export của IL2CPP nên không còn sử dụng được nữa.

Vậy thì nếu ta có thể tìm hết các API là có thể dump được nó theo cách này đúng chứ?

Chuẩn rồi, đó chính là cách mình sẽ làm. Tuy nhiên, việc tìm ra hết các API được sử dụng để dump là một việc rất mất thời gian và chưa kể các API này còn bị obfuscate rất nặng, các struct như Il2CppClass thì bị shuffle mỗi bản build mới, một số còn bị inline.

Vậy nên mình sẽ chỉ tìm một số API cần thiết rồi sử dụng Frida để attach trực tiếp vào game và dump.

Frida là một framework cho phép bạn thực hiện dynamic instrumentation, nghĩa là bạn có thể inject một đoạn code JavaScript hoặc một thư viện ta tự tạo vào trong native app trên bất kỳ hệ điều hành nào như Windows, macOS, Linux, iOS, Android.

Mà mình thấy người ta chủ yếu dùng Frida cho Android/ADB chứ ít thấy ai dùng Frida cho Windows bao giờ :v

Tại sao lại là Frida?

Tiện thì dùng thôi (thực ra do mình lười, và skill issue 😌)

Dù vậy mình vẫn khuyến khích viết một cái dumper hoàn chỉnh hơn là dùng Frida như mình.

Tìm các API cần thiết để dump

Mình sẽ dựa theo cái Il2CppRuntimeDumper này để viết dumper. Nhưng vì một số API được sử dụng trong này đã inline, nhìn khá lú nên mình sẽ tìm cách dễ hơn.

Dump class

Để dump được các class, việc đầu tiên là lấy được các type definition.

Mình sẽ dùng hàm GetTypeInfoFromTypeDefinitionIndex để lấy info class từ type definition index. Hàm này trả về một con trỏ đến Il2CppClass, nghĩa là nó trả về một đối tượng chứa thông tin về class (gọi là klass) mà TypedefIndex đã chỉ đến.

Tìm theo string because generic types cannot have explicit layout. ta có thể dễ dàng thấy nó:

Sau khi lấy được klass, chúng ta có thể dump tên class và namespace bằng cách sử dụng hai API il2cpp_class_get_nameil2cpp_class_get_namespace

2 API này khá dễ tìm, chỉ cần tìm theo string %s%s%s must be instantiated using the ScriptableObject.CreateInstance method instead of new %s. là sẽ thấy:

Chỉ cần ba API trên là đủ để bắt đầu quá trình dump class.

Định nghĩa function với Frida:

const DUMP_FOLDER = "D:/dump";
const DUMP_CS_FILE = DUMP_FOLDER + "/dump.cs";

let file = new File(DUMP_CS_FILE, "w");

const il2cpp_base = Process.findModuleByName("GenshinImpact.exe").base;

const il2cpp_api = {
  il2cpp_MetadataCache_GetTypeInfoFromTypeDefinitionIndex: 0x00588610,
  il2cpp_class_get_name: 0x00A1A7E0,
  il2cpp_class_get_namespace: 0x00A1A7F0,
};

const il2cpp_MetadataCache_GetTypeInfoFromTypeDefinitionIndex = new NativeFunction(il2cpp_base.add(il2cpp_api["il2cpp_MetadataCache_GetTypeInfoFromTypeDefinitionIndex"]), "pointer", ["int"]);
const il2cpp_class_get_name = new NativeFunction(il2cpp_base.add(il2cpp_api["il2cpp_class_get_name"]),"pointer",["pointer"]);
const il2cpp_class_get_namespace = new NativeFunction(il2cpp_base.add(il2cpp_api["il2cpp_class_get_namespace"]),"pointer",["pointer"]);

Hàm GetTypeInfoFromTypeDefinitionIndex nhận một giá trị đầu vào là index, nên mình sẽ cho nó chạy trong một vòng lặp. Vì không rõ có bao nhiêu index, mình sẽ cho chạy đến khi không lấy được kết quả nữa.

function dump_csharp() {
    console.log("Start dumping...");
    for (let i = 0; ; i++)
    {
        let klass;
        try {
            klass = il2cpp_MetadataCache_GetTypeInfoFromTypeDefinitionIndex(i);
        } catch (error) {
            console.log("Finished dumping. Stopping...");
            break;
        }

        let class_name = il2cpp_class_get_name(klass).readCString();
        let class_namespace = il2cpp_class_get_namespace(klass).readCString();

        file.write("// TypedefIndex: " + i + "\n");
        file.write("// Namespace: " + class_namespace + "\n");
        file.write(class_name);
        file.write("\n\n");
    }
    file.flush();
    file.close();
    console.log("Dumped successfully, saved at " + DUMP_CS_FILE);
}

Mở Terminal với quyền admin và nhập command sau để attach cái script này vào Genshin:

frida -f "D:\HoYoPlay\games\Genshin Impact game\GenshinImpact.exe" -l .\rumi.js

và đây là output:

Thực ra như thế này thì cũng được rồi, nhưng mục đích của mình là thay thế output của Il2CppRuntimeDumper nên mình sẽ tìm thêm vài API cần thiết để có thể phân loại class:

Dump method

Nếu bạn thắc mắc tại sao mình có thể tìm ra API chỉ từ một đoạn string, thì đây là cách mình đã làm. Mình so sánh với source code của IL2CPP tìm được trên GitHub

https://github.com/MlgmXyysd/libil2cpp

https://github.com/4ch12dy/il2cpp

Ví dụ để dump method, ta sẽ cần API il2cpp_class_get_methods. Search từ khoá il2cpp_class_get_methods ở 1 trong 2 repo trên:

Ta có thể thấy nó trả về Class::GetMethods(klass, iter). Tiếp tục search với Class::GetMethods:

Thử search ConstructorInfo trong IDA:

Đó, vậy là tìm được rồi 😁

Giờ thì cần tìm il2cpp_method_get_name để có thể lấy được tên method. Tìm theo string Script error (%s): %s.\n là sẽ thấy:

Với 2 API trên là đủ để có thể dump method với RVA (relative virtual address) của nó rồi:

const il2cpp_class_get_methods = new NativeFunction(il2cpp_base.add(il2cpp_api["il2cpp_class_get_methods"]),"pointer",["pointer","pointer"]);
const il2cpp_method_get_name = new NativeFunction(il2cpp_base.add(il2cpp_api["il2cpp_method_get_name"]),"pointer",["pointer"]);

function dump_method(klass) {
    let method,
        method_name,
        method_pointer,
        mstr;

    const iter = Memory.alloc(Process.pointerSize);
    method = il2cpp_class_get_methods(klass, iter);
    file.write("\n\t// Methods\n");
    while (!method.isNull()) {
        mstr = "\t";
        method_name = il2cpp_method_get_name(method).readCString();
        method_pointer = method.readPointer().sub(il2cpp_base);
        mstr += method_name + " // RVA: " + method_pointer + "\n";
        file.write(mstr);
        method = il2cpp_class_get_methods(klass, iter);
    }
}

và đây là output:

Nhưng trông vẫn còn khá hạn chế, để đầy đủ hơn thì cần thêm các API sau:

  • il2cpp_method_get_return_type: Lấy kiểu trả về của method
  • il2cpp_method_get_param_count: Đếm số lượng param có trong method
  • il2cpp_method_get_param: Lấy param của method
  • il2cpp_method_get_param_name: Lấy tên param

Vậy là đã dump được method 🥳:

Dump field

Tìm Class::GetFields trong source:

IDA:

Có thể thấy field->nameil2cpp_field_get_name ở đây đã bị inline.

v15 = (const char *)(*(_QWORD *)(v13 + 8) - 0x7A9AB96D7F826892i64);
if ( strcmp("value__", v15) )

Mình sẽ tìm nó bằng cách search Immediate value (Alt + I):

Tìm thêm vài API còn lại để dump field và ta sẽ có:

const il2cpp_class_get_fields = new NativeFunction(il2cpp_base.add(il2cpp_api["il2cpp_class_get_fields"]),"pointer", ["pointer", "pointer"]);
const il2cpp_field_get_name = new NativeFunction(il2cpp_base.add(il2cpp_api["il2cpp_field_get_name"]),"pointer", ["pointer"]);
const il2cpp_field_get_flags = new NativeFunction(il2cpp_base.add(il2cpp_api["il2cpp_field_get_flags"]), "int", ["pointer"]);
const il2cpp_field_static_get_value = new NativeFunction(il2cpp_base.add(il2cpp_api["il2cpp_field_static_get_value"]), "pointer", ["pointer", "pointer"]);
const il2cpp_field_get_type = new NativeFunction(il2cpp_base.add(il2cpp_api["il2cpp_field_get_type"]), "pointer", ["pointer"]);

function dump_field(klass) {
    let field,
        field_name,
        fstr;
    const iter = Memory.alloc(Process.pointerSize);
    field = il2cpp_class_get_fields(klass, iter);
    file.write("\n\t// Fields\n");
    while(!field.isNull()) {
        fstr = "\t";
        const fieldFlags = il2cpp_field_get_flags(field);
        field_name = il2cpp_field_get_name(field).readCString();

		let field_type = il2cpp_field_get_type(field);
		let field_class = il2cpp_class_from_type(field_type);
        let field_class_name = il2cpp_class_get_name(field_class);

        fstr += field_flag_string(field, 0) + ptr(field_class_name).readCString() + " " + field_name;
        let is_enum = il2cpp_class_is_enum(klass);
        if (fieldFlags & 0x0040 && is_enum) {
            const val = Memory.alloc(Process.pointerSize);
            Memory.writePointer(val, NULL);
            il2cpp_field_static_get_value(field, val);
            fstr += (" = " + val.readU64());
        }
        fstr += ";" + "\n";
        file.write(fstr);
        field = il2cpp_class_get_fields(klass, iter);
    }
}

output:

Kết

Đó là tất cả những gì mình tìm được sau cả một tuần ngồi reverse Genshin, không thể phủ nhận rằng Michos đã làm rất tốt trong việc chống lại cheater/leaker.

Phương pháp này chưa phải là cách tối ưu nhất để dump, và cũng còn nhiều hạn chế, nhưng với kỹ năng hiện tại thì đây là những gì mình có thể làm được 😅

Hy vọng bài viết này sẽ giúp bạn có thêm kiến thức và mở ra hướng nghiên cứu cho bản thân ( ๑‾̀◡‾́)

References

Il2CppRuntimeDumper by lilmayofuksu

RuntimeDumper by kuma-dayo