Dump il2cpp 通常有兩種方法,一種是用 Il2CppDumper 加載二進制文件直接dump. 另一種是用Zygisk-Il2CppDumper注入目標進程來dump il2cpp.接下來介紹一種我常用的方法。

原理

Unicorn 是一個CPU模擬框架,Qiling是基于Unicorn的一個系統模擬框架。通過Qiling我們可以模擬一個進程的執行環境,因此我們可以通過將目標進程的內存空間整個dump下載,然后用Qiling加載這個進程的內存映像,同時我們將dumper編譯成payload,同樣加載到模擬的內存空間里,最后通過直接運行payload來dump。

工具

mypower(https://bbs.kanxue.com/thread-277252.htm) 一個內存掃描工具,這里主要主要用來dump進程的完整內存。

Qiling(https://qiling.io/) 一個系統模擬框架。

準備

首先用 mypower 將目標進程dump下來。啟動mypoewr后運行以下命令可得到進程的內存鏡像。

attach -p pid
snapshot data

輸出的兩個文件分別是data.memory和data.json, 前者是內存的內容,后者是內存區域的描述信息。

接著準備Qiling框架的配置信息:dump.ql

[OS64]
load_address = 0x8FFF000000000000
stack_address = 0x8FFF800000000000
stack_size = 0x8000000
mmap_address = 0

根據 data.json 的信息,我們將payload加載到0x8FFF000000000000附近是安全的。

加載內存鏡像

以下是將mypower工具dump下來的進程內存加載到qiling框架的腳本:

def load_memory(mu):
    idx = 0
    for region in memory_info["regions"]:
        file = region["file"]
        if file.startswith("/dev/kgsl") or region["prot"] == 0 or region["desc"].endswith("stack]"):
            continue
        if 'aidl' in file or 'hidl' in file or 'vndk' in file or 'android.hardware' in file \
                or file.endswith("dex") or file.endswith("jar") or file.endswith("apk") or file.endswith("art") \
                or file.endswith("oat") or 'dalvik' in file or 'dalvik' in region['desc'] or file.startswith('/vendor') or 'hardware' in file:
            continue
        size = region["end"] - region["begin"]
        mu.mem_map(region["begin"], size)
        memory_data.seek(region["saved_offset"], 0)
        mem = memory_data.read(region["saved_size"])
        mu.mem_write(region["begin"], mem)
        del mem
        print(f"Load {idx}/{len(memory_info['regions'])} {region['begin']:x}-{region['end']:x} {size} {region['file']} {region['desc']}")
        idx += 1
ql = Qiling(["dump.elf"],
    rootfs='./rootfs', 
    verbose=QL_VERBOSE.OFF, 
    profile='./dump.ql',
    ostype="Linux",
    archtype="ARM64")
load_memory(ql.uc)

這里涉及到的內容主要是Unicorn的API的使用。先將data.json解析出來根據信息讀取data.memory,然后用Unicorn的mem_write寫到虛擬機的內存空間中。這里的dump.elf是我們的dumper,源碼來自https://github.com/Perfare/Il2CppDumper,這部分內容下一節介紹。

在運行任何代碼前,我們還要準備運行環境,主要是準備一個合法的進程TLS區域,TCB線程控制信息,和線程棧:

END_ADDRESS = 0x55aa55aa55aa55aa
STACK_ADDRESS = 0x8FFF800000000000
STACK_SIZE = 0x8000000
TLS_ADDRESS = STACK_ADDRESS + 0x1000
TCB_ADDRESS = TLS_ADDRESS + 0x1000
BIONIC_TLS_ADDRESS = TCB_ADDRESS + 0x1000
# TLS
tls = struct.pack('    BIONIC_TLS_ADDRESS, # BIONIC_TLS
    0, # DTV
    TCB_ADDRESS, # THREAD ID
    # 0x778ca4d508-16,
    0, # APP
    0, # OGL
    0, # OGL API
    0, # STACK GUARD
    0, # SANITIZER
    0, # ART THREAD TLS
)
ql.uc.mem_write(TLS_ADDRESS, tls)
class pthread_attr_t(ctypes.Structure):
    _fields_ = [
        ("flags", ctypes.c_uint32),
        ("stack_base", ctypes.c_void_p),
        ("stack_size", ctypes.c_size_t),
    ]
class pthread_internal_t(ctypes.Structure):
    _fields_ = [
        ("next", ctypes.c_void_p),
        ("prev", ctypes.c_void_p),
        ("tid", ctypes.c_int),
        ("cache_pid_and_vforked", ctypes.c_uint32),
        ("attr", pthread_attr_t),
    ]
thread_attr = pthread_attr_t(0, STACK_ADDRESS, STACK_SIZE)
thread = pthread_internal_t(0, 0, 0, 0, thread_attr)
ql.uc.mem_write(TCB_ADDRESS, bytes(thread))
ql.uc.reg_write(UC_ARM64_REG_TPIDR_EL0, TLS_ADDRESS + 8)

因為本文針對的是ARM64的安卓平臺,因此這里提供的TCB TLS數據結構不適用別的平臺架構,這點要注意。

最后一項工作是準備堆和payload所需要的一些參數。

因為我們是用模擬器來模擬執行,是沒辦法調用原系統的功能的,因此堆內存分配這部分功能是是無效的,我們要提供額外的堆:

HEAP_ADDRESS = STACK_ADDRESS + 0x8000
def malloc(*args):
    global HEAP_ADDRESS
    sz = ql.uc.reg_read(UC_ARM64_REG_X0)
    ql.uc.reg_write(UC_ARM64_REG_X0, HEAP_ADDRESS)
    HEAP_ADDRESS += (sz + 15) & ~15
    # print(f"[py] malloc {sz}")
def free(*args):
    pass
def calloc(*args):
    global HEAP_ADDRESS
    n = ql.uc.reg_read(UC_ARM64_REG_X0)
    sz = ql.uc.reg_read(UC_ARM64_REG_X1) * n
    ql.uc.reg_write(UC_ARM64_REG_X0, HEAP_ADDRESS)
    HEAP_ADDRESS += (sz + 15) & ~15
    # print(f"[py] calloc {sz}")
def realloc(*args):
    global HEAP_ADDRESS
    ptr = ql.uc.reg_read(UC_ARM64_REG_X0)
    sz = ql.uc.reg_read(UC_ARM64_REG_X1)
    # print(f"[py] realloc 0x{ptr:x} {sz}")
    if ptr != 0:
        if sz == 0:
            # free
            return
        data = ql.uc.mem_read(ptr, sz)
        ql.uc.mem_write(HEAP_ADDRESS, bytes(data))
    ql.uc.reg_write(UC_ARM64_REG_X0, HEAP_ADDRESS)
    HEAP_ADDRESS += (sz + 15) & ~15
MALLOC_ADDR, FREE_ADDR, CALLOC_ADDR, REALLOC_ADDR = libc.get_funcs(memory_info, memory_data, 'libc.so', ['malloc', 'free', 'calloc', 'realloc'])
IL2CPP_BASE_DATA, IL2CPP_BASE_ADDR, IL2CPP_BASE_END = libc.read_so(memory_info, memory_data, "libil2cpp.so")
print(f'malloc 0x{MALLOC_ADDR:x}')
print(f'free 0x{FREE_ADDR:x}')
print(f'calloc 0x{CALLOC_ADDR:x}')
print(f'dlsym 0x{DLSYM_ADDR:x}')
ql.uc.mem_write(MALLOC_ADDR, b'\xC0\x03\x5F\xD6') # ret
ql.uc.mem_write(FREE_ADDR, b'\xC0\x03\x5F\xD6') # ret
ql.uc.mem_write(CALLOC_ADDR, b'\xC0\x03\x5F\xD6') # ret
ql.uc.mem_write(REALLOC_ADDR, b'\xC0\x03\x5F\xD6') # ret
ql.uc.hook_add(UC_HOOK_CODE, malloc, None, MALLOC_ADDR, MALLOC_ADDR + 4)
ql.uc.hook_add(UC_HOOK_CODE, free, None, FREE_ADDR, FREE_ADDR + 4)
ql.uc.hook_add(UC_HOOK_CODE, calloc, None, CALLOC_ADDR, CALLOC_ADDR + 4)
ql.uc.hook_add(UC_HOOK_CODE, realloc, None, REALLOC_ADDR, REALLOC_ADDR + 4)

原理就是預留一段足夠的內存,然后hook幾個堆內存函數,將堆分配函數用python實現。這里給的例子是幾個簡單的只分配不釋放的堆內存函數。

我們的dumper運行起來還需要dlsym來查找il2cpp導出的函數:

DLSYM_ADDR, = libc.get_funcs(memory_info, memory_data, 'libdl.so', ['dlsym'])
dlsym_code = ql.uc.mem_read(DLSYM_ADDR, 16 * 4)
# mov lr, x2 => mov x2, x2
ql.uc.mem_write(DLSYM_ADDR + dlsym_code.find(bytes.fromhex('E2 03 1E AA')), bytes.fromhex('e2 03 02 aa'))

為了繞過安卓的dll命名空間機制,我們還需要堆dlsym打一個補丁,使其能順利找到il2cpp相關的函數。原理是安卓會通過調用dlsym的返回地址來決定從哪個命名空間查找符號,要將由LR提供的返回地址改為由第三個參數提供返回地址,我們的payload在調用dlsym的時候第三個參數會輸入libil2cpp.so的地址,如此方能順利獲得il2cpp系列函數。

這些都做了就能啟動虛擬機執行我們的payload了。

try:
    ql.run(end=END_ADDRESS)
except:
    pc = ql.uc.reg_read(UC_ARM64_REG_PC) - 8
    print(f"pc 0x{pc:x}")
    dis = ql.arch.disassembler
    code = ql.uc.mem_read(pc, 4 * 8)
    print(code.hex())
    for i in list(dis.disasm(code, pc)):
        print("0x%x:\t%s\t%s" %(i.address, i.mnemonic, i.op_str))
    raise

Payload

我們的payload主要是一個修改了的Il2CppDumper,主要修改的地方是使其接受打好補丁的dlsym和一個依賴較少的格式化字符串輸出工具類。

#include "il2cpp-tabledefs.h"
#include "il2cpp-class.h"
#define DO_API(r, n, p) r (*n) p
#include "il2cpp-api-functions.h"
#undef DO_API
typedef void* (*dlsym_t)(void *handle, const char *symbol, const void* caller_addr);
void _uprint(int fd, const char* format, ...) {
    char buffer[4096];
    va_list args;
    va_start(args, format);
    auto n = vsnprintf(buffer, 4096, format, args);
    va_end(args);
    write(fd, buffer, n);
}
#define uprint(...) _uprint(2, __VA_ARGS__)
int dump_fd = 2;
uint64_t _il2cpp_base = 0;
dlsym_t _dlsym = 0;
void init_il2cpp_api() {
#define DO_API(r, n, p) {                      \
    n = (r (*) p)_dlsym(NULL, #n, (void*)_il2cpp_base); \
    if(!n) {                                   \
        uprint("api not found %s", #n);          \
    }                                          \
}
#include "il2cpp-api-functions.h"
#undef DO_API
}
struct Dump {
    const Dump& operator <<(const char* str) const {
        _uprint(dump_fd, "%s", str);
        return *this;
    }
    const Dump& operator <<(unsigned long val) const {
        _uprint(dump_fd, "%lu", val);
        return *this;
    }
    const Dump& operator <<(long val) const {
        _uprint(dump_fd, "%ld", val);
        return *this;
    }
};
const Dump dump{};
。。。
void dump_method(Il2CppClass *klass) {
    。。。
}
void dump_property(Il2CppClass *klass) {
    。。。
}
void dump_field(Il2CppClass *klass) {
    。。。
}
void dump_type(const Il2CppType *type) {
    。。。
}
extern "C"
int entry(dlsym_t dlsym, uintptr_t il2cpp_base)
{
    _dlsym = dlsym;
    _il2cpp_base = il2cpp_base;
    uprint("payload dlsym %p", dlsym);
    dump_fd = open("/dump.cs", O_RDWR | O_CREAT, 0777);
    。。。
    uprint("done...");
    return 0;
}

功能大概是首先打開一個文件dump.cs,然后調用Il2CppDumper的dumpAPI來輸出得到的內容寫到此文件。

源代碼

完整代碼在Github上,qiling-il2cpp-dump(https://github.com/vrolife/qiling-il2cpp-dump)

后記

這套方案除了能用來dump il2cpp還可以用來做一些調試,只要編譯對應的payload就行了。這個方案有個缺點就是運行速度比較慢,比如dump某5v5游戲大約需要30分鐘左右。

任何人不得將本文介紹的技術用于任何違法亂紀的事情,違者后果自負。