前言

没想到距离上次发一篇教程性质的文章已经过去整整一年了,刚好最近搞了标题里提到的东西觉得还是可以写一下的,于是就来水了这一篇。

之前我一直都没有在模拟器上装过magisk,修改游戏也都是用自己的手机直接搞。不过去年年底新出的一款游戏非常火,跟群里的大佬一起搞了一个模块给群友用,很快就被问到不支持模拟器吗,我心想模块有编译arm和x86的版本,应该是没有问题的吧,结果测试了一下,发现还真不能用,稍微研究了一下才发现要让模块支持模拟器上的游戏还是要一定操作的。

提前说下,下面的代码只在64位的模拟器夜神安卓7,安卓9和雷电5(安卓7),雷电9(安卓9)上测试过。

实现

在现在的64位模拟器中,zygisk默认载入的是x86_64的模块,而如果游戏没有原生x86_64的so,那它依然会使用arm64-v8a的so,这样自然就没法用x86_64的模块修改arm64的游戏,所以要做的就是想办法把我们模块的arm64的so加载到游戏中,直接使用dlopen肯定是不行的,搜索一番后发现frida已经有一套完整的解决方案,所以我们要做的就是,照抄啦~

frida的做法是使用NativeBridge的API来载入arm的so,那么谁来调用这个api呢,那自然是已经加载到游戏中的x86模块来做了,所以先在x86模块中添加一段代码:判断一下游戏是否使用了arm的so,如果使用了再进行接下来的操作。载入so所要用到的NativeBridge API一共有三个,分别是NativeBridgeLoadLibraryNativeBridgeLoadLibraryExtNativeBridgeGetTrampoline。两个LoadLibrary就是用来载入so的,其中NativeBridgeLoadLibraryExt是在安卓8(api 26)及以后才添加的,用于有namespace限制的情况,也就是说安卓8之前使用NativeBridgeLoadLibrary,安卓8之后使用NativeBridgeLoadLibraryExt。至于NativeBridgeGetTrampoline则是用于so载入后获取入口函数来启动修改。

那么首先初始化NativeBridge的API,因为后面调用NativeBridgeGetTrampoline需要用到JNI_GetCreatedJavaVMs,所以也顺带获取下。

auto libnativebridge = dlopen("libnativebridge.so", RTLD_NOW);
LOGI("libnativebridge.so %p", libnativebridge);
typedef void *(*native_bridge_loadLibrary)(const char *libpath, int flag);
typedef void *(*native_bridge_loadLibraryExt)(const char *libpath, int flag, void *ns);
typedef void *(*native_bridge_getTrampoline)(void *handle, const char *name,
                                                const char *shorty, uint32_t len);
auto loadLibrary = (native_bridge_loadLibrary) dlsym(libnativebridge,
                                                        "NativeBridgeLoadLibrary");
native_bridge_loadLibraryExt loadLibraryExt;
native_bridge_getTrampoline getTrampoline;
if (loadLibrary) {
    loadLibraryExt = (native_bridge_loadLibraryExt) dlsym(libnativebridge,
                                                            "NativeBridgeLoadLibraryExt");
    getTrampoline = (native_bridge_getTrampoline) dlsym(libnativebridge,
                                                        "NativeBridgeGetTrampoline");
} else {
    loadLibrary = (native_bridge_loadLibrary) dlsym(libnativebridge,
                                                    "_ZN7android23NativeBridgeLoadLibraryEPKci");
    loadLibraryExt = (native_bridge_loadLibraryExt) dlsym(libnativebridge,
                                                            "_ZN7android26NativeBridgeLoadLibraryExtEPKciPNS_25native_bridge_namespace_tE");
    getTrampoline = (native_bridge_getTrampoline) dlsym(libnativebridge,
                                                        "_ZN7android25NativeBridgeGetTrampolineEPvPKcS2_j");
}
LOGI("NativeBridgeLoadLibrary %p", loadLibrary);
LOGI("NativeBridgeLoadLibraryExt %p", loadLibraryExt);
LOGI("NativeBridgeGetTrampoline %p", getTrampoline);
auto libart = dlopen("libart.so", RTLD_NOW);
auto JNI_GetCreatedJavaVMs = (jint (*)(JavaVM **, jsize, jsize *)) dlsym(libart,
                                                                            "JNI_GetCreatedJavaVMs");
LOGI("JNI_GetCreatedJavaVMs %p", JNI_GetCreatedJavaVMs);

api初始化完了就该载入so了,当然so需要事先放到游戏能读取的地方,比如/data/local/tmp/

其中api版本判断可以改成判断loadLibraryExt是否存在

void *arm_handle;
if (api_level >= 26) {
    arm_handle = loadLibraryExt(path, RTLD_NOW, (void *) 3);
} else {
    arm_handle = loadLibrary(path, RTLD_NOW);
}
LOGI("arm handle %p", arm_handle);

so成功载入后就可以使用NativeBridgeGetTrampoline获取入口函数启动修改了,不过NativeBridgeGetTrampoline实际上获取的是一个Trampoline函数,在这个函数中最后会调用我们的入口函数,并且支持的函数名只能是JNI_OnLoad,所以我们先在so中添加JNI_OnLoad函数

JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) {
    //Do bad things here
    return JNI_VERSION_1_6;
}

接下来调用NativeBridgeGetTrampoline并启动修改

JavaVM *vms_buf[1];
jsize num_vms;
jint status = JNI_GetCreatedJavaVMs(vms_buf, 1, &num_vms);
if (status == JNI_OK && num_vms > 0) {
    auto init = (void (*)(JavaVM *vm, void *reserved)) getTrampoline(arm_handle,
                                                                        "JNI_OnLoad", nullptr,
                                                                        0);
    LOGI("JNI_OnLoad %p", init);
    init(vms_buf[0], nullptr);
}

到这里使用x86的so当loader载入arm的so并启动修改的流程就完成了

更优雅一点的方法

1.用一个符号初始化NativeBridge Api

看上面的代码就会发现,NativeBridge API每个函数都得单独通过符号获取,而且还存在不同版本下有不同符号的可能,所以有没有更优雅一点的方式呢?

通过浏览安卓的源码,会发现NativeBridge API实际上都是调用一个结构为NativeBridgeCallbacks的callbacks,而这个结构体根据NativeBridge的接口规范是需要在库中导出的,符号是固定的NativeBridgeItf,NativeBridge库通常是libhoudini.so,不过有些模拟器用的不是这个名字,可以从ro.dalvik.vm.native.bridge中获取。

那么接下来事情就变的简单了,只需要获取这个符号,就完成了我们上面的api初始化工作。

static std::string GetNativeBridgeLibrary() {
    auto value = std::array<char, PROP_VALUE_MAX>();
    __system_property_get("ro.dalvik.vm.native.bridge", value.data());
    return {value.data()};
}

//v3
struct NativeBridgeCallbacks {
    uint32_t version;
    void *initialize;
    void *(*loadLibrary)(const char *libpath, int flag);
    void *(*getTrampoline)(void *handle, const char *name, const char *shorty, uint32_t len);
    void *isSupported;
    void *getAppEnv;
    void *isCompatibleWith;
    void *getSignalHandler;
    void *unloadLibrary;
    void *getError;
    void *isPathSupported;
    void *initAnonymousNamespace;
    void *createNamespace;
    void *linkNamespaces;
    void *(*loadLibraryExt)(const char *libpath, int flag, void *ns);
};

auto nb = dlopen("libhoudini.so", RTLD_NOW);
if (!nb) {
    auto native_bridge = GetNativeBridgeLibrary();
    LOGI("native bridge: %s", native_bridge.data());
    nb = dlopen(native_bridge.data(), RTLD_NOW);
}
if (nb) {
    LOGI("nb %p", nb);
    auto callbacks = (NativeBridgeCallbacks *) dlsym(nb, "NativeBridgeItf");
    if (callbacks) {
        LOGI("NativeBridgeLoadLibrary %p", callbacks->loadLibrary);
        LOGI("NativeBridgeLoadLibraryExt %p", callbacks->loadLibraryExt);
        LOGI("NativeBridgeGetTrampoline %p", callbacks->getTrampoline);
    }
} 

但是如果模拟器用的不是libhoudini.so并且也不在ro.dalvik.vm.native.bridge中定义的话,这种方法还是会失败,不过目前的模拟器应该都遵循这个规范。

2.从内存中载入so

在上面的代码中,我们加载so是从/data/local/tmp/加载的,必须事先把它放到这个目录,而且谁也不知道如果游戏有加固会不会丧心病狂的检测这个目录。不过Linux其实有个从内存加载so的技巧,就是使用memfd_create创建一个匿名共享内存文件,然后把so的数据写入这个共享内存,就可以通过dlopen /proc/self/fd来加载so。所以我们是不是可以直接从magisk模块的安装目录中读取so到内存中然后再dlopen呢?magisk的模块安装位置是/data/adb,这个文件夹普通的用户程序是没法读取的,不过仔细查看zygisk的api可以发现,在preAppSpecialize的时候程序没有任何沙箱限制,并且与zygote拥有相同的权限,所以我们可以在这个时候先把so读取到内存,之后再载入。

void preSpecialize(const char *package_name, const char *app_data_dir) {
    if (strcmp(package_name, GamePackageName) == 0) {
        //Do other things

#if defined(__i386__)
        auto path = "zygisk/armeabi-v7a.so";
#endif
#if defined(__x86_64__)
        auto path = "zygisk/arm64-v8a.so";
#endif
#if defined(__i386__) || defined(__x86_64__)
        int dirfd = api->getModuleDir();
        int fd = openat(dirfd, path, O_RDONLY);
        if (fd != -1) {
            struct stat sb{};
            fstat(fd, &sb);
            length = sb.st_size;
            data = mmap(nullptr, length, PROT_READ, MAP_PRIVATE, fd, 0);
            close(fd);
        } else {
            LOGW("Unable to open arm file");
        }
#endif
    }
}

接下来我们只要在调用NativeBridgeLoadLibrary之前,把它copy到匿名共享内存文件中就行了

int fd = syscall(__NR_memfd_create, "anon", MFD_CLOEXEC);
ftruncate(fd, length);
void *mem = mmap(nullptr, length, PROT_WRITE, MAP_SHARED, fd, 0);
memcpy(mem, data, length);
munmap(mem, length);
munmap(data, length);
char path[PATH_MAX];
snprintf(path, PATH_MAX, "/proc/self/fd/%d", fd);

之后直接loadLibrary这个path就行了。完整代码可以看Zygisk-Il2CppDumper

一个没啥用的发现

测试的时候发现,除了安卓7以外,其他版本的安卓可以直接用dlsym处理NativeBridgeLoadLibrary返回的handle,这样就不需要调用NativeBridgeGetTrampoline,入口函数也可以不限制为JNI_OnLoad,导出任意一个函数就行。

一个过手游反作弊中.text段校验的方法

原本打算下个月再水的,不过转念一想,反正也不可能做到每个月都水一篇,而且现在放开以后准备到处去玩了,所以还是赶紧写完了发出来。 偷梁换柱 思路...

阅读全文

使用Zygisk和Il2Cpp Api来修改游戏吧

在两年前发布Riru版Il2CppDumper的时候,就打算发布这篇配套的文章,结果码了一半就被我丢到了一旁,后来每次想起来的时候总是以”既然代码都发布了大概也不...

阅读全文

使用Riru修改手游

前篇 手游的注入与修改 使用VirtualXposed修改手游 前言 时隔半年终于把这坑填上了,一开始只是打算随便写写,不过现在看来刚好可以凑出一个系列,之前的文章...

阅读全文

20 条评论

  1. 大佬 我看你的代码如果在armeabi-v7a的情况下会执行hack_prepare函数中的 hack_start(game_data_dir);,同时也会存在jni_onload函数,这里面也会执行hack_start(game_data_dir);,这样会不会重复执行,从而dump两次?

  2. 夜神的安卓7 64位用起来会有问题,根据issue#132的方法确实能解决问题。根据我的测试,模拟器本身环境是x86_64,但载入的x86_64模块会选择载入的是armabi-v7a,而不是arm64-v8a,但测试过无论用哪个so,loadLibrary的返回值都是0,匪夷所思 :?: ,而且安卓7还没有getError函数看出错信息。夜神的安卓9 64位却没这个问题。

    1. 首先x86_64的模块只会载入arm64-v8a的so,这个是在代码里写死的除非你自己改过代码。
      夜神安卓7 64位测试过没有任何问题。loadLibrary能不能用得看你当前游戏的abi,如果你游戏本身abi就是x86的loadLibrary不管载入哪个so当然都是失败的

    2. 当时我加了LOG看到加载确实是armabi-v7a.so,但模拟器环境getprop ro.product.cpu.abi拿到的是x86_64。 那应该是zygisk加载了x86模块(为何不选x86_64?),然后模块选了armabi-v7a.so。

      一开始我也怀疑过是不是游戏abi问题,检查过,安装的游戏abi也确实是armabi-v7a,也试过写死注入的so,armabi-v7a,arm64-v8a,x86,x86_64都注入试过,全失败,所以说非常的匪夷所思。(7.0.5.5版本)。

      adb install –abi arm64-v8a,后注入的模块选择arm64-v8a.so注入(zygisk应该加载了x86_64),能成功dump。

      不知道是不是个例。
      本想着用安卓9看看getError信息的,结果9是没这个问题的。
      9安装的游戏abi也确实是armabi-v7a,zygisk加载了x86模块,模块注入了armabi-v7a.so,然后成功dump

    3. 我今天添加了abi判断的代码,现在可以自动判断abi载入对应的so,然后顺带测试了下夜神安卓7下所有abi的情况,发现abi是armabi-v7a时无法成功loadLibrary,其他abi版本和夜神安卓9都没有这个问题,这几天有空再看看是什么情况了,不过可能不会解决,毕竟我觉得现在应该都会首选性能更好的安卓9才对,更别说现在都已经有安卓11和12的模拟器了

  3. 大佬,我是android11,拉了你的II2CppDumper项目当模板,自己写了个模块,想读取配置文件,但是开io流的时候失败,请问下这是什么问题?c++和安卓我都不太熟悉

    1. 首先感谢大佬回答,那估计是读写权限的问题了,java写多了,太依赖异常了,c++读写文件没权限不会抛异常之类的东西嘛

欢迎留言

3 + 2 =