关于 Android 的几种 Hook 方法研究

2022-02-07 更新:Riru 已 Archived,本文所用方法已过期,应使用最新的 Zygisk 进行 Hook

最近为了找到某游戏里的一些数据,开始研究 hook 方法。

结果就是……一路上碰到数不清的坑,撞了数不清的墙(

不过还好最后总算找到了能够成功 hook Unity 制游戏的方法(当然不限于 Unity),在此按照我踩坑的先后顺序把各种方法都记录一下。

1. 采用 Frida 注入

在 Google 上随便一搜,首先找到的就是 Frida。去仓库瞟了一眼 8k+ stars,嗯应该挺靠谱的,看了看示例,实时动态交互的注入的方式确实非常强大。于是我 clone 下来开始研究,没想到这是个噩梦的开始。

首先 Frida 分为本体和 Server 端,本体运行在 PC 上,分为 frida 和 frida-tools,两者都需要安装,Server 运行在手机上,本体和 Server 端版本必须相同。

前期准备一切就绪后,我兴冲冲地进入手机运行 Server 端,结果踩了第一个坑

image

……等了5分钟,没反应。

What’s the hell going on here??

于是我去翻官方文档,官方只是无责任地给了一句

Also note that most of our recent testing has been taking place on a Pixel 3 running Android 9…… We cannot test on all possible devices, so we count on your help to improve on this.

好吧,毕竟确实是这样,也不能怪官方。

然后有点记不得了我后来干了什么,好像是关闭了 Magisk Hide 之后重启,莫名其妙就跑起来了(

总之乱打乱撞总算把 Server 跑起来了,那么开始实验吧。于是我随便找了一个比较熟悉的并且没有任何防护措施的 app,准备用来做注入测试,结果踩了第二个坑

image

这次又咋了?

在网上查了半天,原来是需要把 selinux 调成关闭状态。直接在 shell 里用 setenforce 0 即可关闭。

把 selinux 关闭之后再注入就成功了。

测试完后,我把写好的游戏注入用 js 准备好,开始正式注入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
var moduleName = "libil2cpp.so"; 
var nativeFuncAddr = 0x13B3D84;

Interceptor.attach(Module.findExportByName(null, "dlopen"), {
onEnter: function(args) {
this.lib = Memory.readUtf8String(args[0]);
console.log("dlopen called with: " + this.lib);
},
onLeave: function(retval) {
if (this.lib.endsWith(moduleName)) {
console.log("ret: " + retval);
var baseAddr = Module.findBaseAddress(moduleName); // 找到module基址
Interceptor.attach(baseAddr.add(nativeFuncAddr), { // 加上方法相对地址
onEnter: function(args) { // args为原方法的参数
console.log("[-] hook invoked");
//var keyDump = Memory.readByteArray(args[0], 0x300);
//var keyDump2 = Memory.readByteArray(args[1], 0x300);
//console.log(hexdump(keyDump));
//console.log(hexdump(keyDump2));
console.log(JSON.stringify({
a0: args[0],
a1: args[1],
a2: args[2]
}, null, '\t'));
},
onLeave: function (retval) {
console.log(retval);
//var keyDump2 = Memory.readByteArray(retval, 0x300);
//console.log(hexdump(keyDump2));
}
});
}
}
});

却发现,对于一些老的 Unity 游戏(比如 CGSS)能够成功注入,对于新的则是无论如何测试游戏都会在启动的瞬间崩溃。关于崩溃的原因,我去翻了下 logcat,是个 SIGSEGV。

我估计是新版 Unity 自带了反调试检测?总之这条路是走不通了。

2. 采用 Xposed Hook

Frida 卒后,于是我把大名鼎鼎的 Xposed 翻了出来。

然而,从 Xposed 官方文档里就能看到,这个框架只能 hook Java 层,对于 native 层是无能为力的。而我们需要 hook 的游戏经过 il2cpp 处理全都是 native 函数,所以这条路也行不通。

3. 改造 so 文件重新打包

4. 改造 so 文件 + Exposed Hook 加载

于是我想到这两种方法。

这两种方法原理是一样的,只不过第一种需要对 apk 重新打包签名,如果碰到 apk 做了签名验证就没辙了。而第二种方法,由于 Android 在读取 lib 时用到的方法是 Java 层的,所以可以另辟蹊径用 Xposed hook 掉这个读取方法,并将原本要读取的 so 文件换成我们自己改造过的 so 文件来达成目的。

但这两种方法都相当于 inline-hook,需要写汇编码,同时还会受到原本字节码的空间限制,成本太高(死都不会读汇编),所以几乎可以抛弃掉了。继续想别的办法。

5. 正面杠,反反调试

行吧,你不是反调试吗,我就来反反调试!

大概思路是这样的。Hook 主体采用 Frida,但由于游戏有反调试机制,所以需要先使用 Xposed hook 应用加载 so 库,改为加载改造过的 so 库跳过调试检测。

可是这看起来这和 3、4 没什么区别啊?而且还多了一步用 frida?

其实还是有区别的。3、4 这种直接改造 so 库的方法需要重写大量的汇编码,而这里我们只需要把反调试的部分改掉,也就是说只需要找到反调试的逻辑部分,直接 NOOP 掉或者 BL 就可以了,在汇编层的改动量很小,剩下的交给 frida 用高级语言写就可以了。

然而这种方法需要找到 so 库里面所有的反调试逻辑地址再一个一个 NOOP,工作量巨大,并且游戏一旦更新就需要重新修改。

所以这条路也不太现实,我可不是抖M……


到这里其实当时我已经陷入万策尽的状态了,于是放置了一段时间,甚至感觉 Android 太难还简单研究了下 iOS 的 hook 方法。

后来我突然想到,Xposed 不是靠 Riru 来实现的注入吗,为什么我不能直接用 Riru?

于是就有了下面这个思路。

6. 直接采用 Riru 注入

简单来说,Riru 可以在 Zygote 被 fork 时对其方法进行劫持,而且由于 Zygote 的 fork 函数本身就是 native 层的(nativeForkAndSpecialize),所以可以直接对 native 层进行 hook。

可是,等一下。

虽然我们可以 hook native 层,并且可以通过 IDA 等工具找到目标函数的相对地址,可以直接主动对函数进行调用。但是对于被动调用的情况该怎么办?

于是我又开始到处找资料,最后在 gayhub 上找到了双草酸酯大佬的解决方案。

7. 采用 Riru + Dobby 进行 Hook

点开双草酸酯大佬的 repo,克隆下来,简单研究一下。

首先还是通过 Riru 给出的 forkAndSpecializePre 进行包名检测,在 forkAndSpecializePost 中创建一个新的线程进行 hook

1
2
3
4
5
6
7
8
if (enable_hack) {
int ret;
pthread_t ntid;
// 在 hack_thread 中实现 hook 逻辑
if ((ret = pthread_create(&ntid, nullptr, hack_thread, nullptr))) {
LOGE("can't create thread: %s\n", strerror(ret));
}
}

hack_thread 中,使用 dobby 被动 hook dlopen 函数,如果被调用则执行自定义逻辑获取句柄

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void* dlopen_(const char* name,
int flags,
//const void* extinfo,
const void* caller_addr){
// 调用原函数获取句柄
void* handle = dlopen_backup(name, flags, /*extinfo,*/ caller_addr);
if(!il2cpp_handle){
LOGI("Riru-hook: dlopen: %s", name);
// 如果是目标 so 库,则将句柄复制保存到自定义的变量中,方便后续使用
if(strstr(name, "libil2cpp.so")){
il2cpp_handle = handle;
LOGI("Riru-hook: Got il2cpp handle at %lx", (long)il2cpp_handle);
}
}
return handle;
}

由于是被动 hook,所以为了防止程序在没有启动游戏的情况下继续执行,所以建立一个死循环,直到检测到 libil2cpp.so 被读入内存 map /proc/self/maps 中,则判定为游戏启动,跳出循环执行后续逻辑

1
2
3
4
5
6
7
8
while (true)
{
// 获取 module 基址,找到则跳出循环
base_addr = get_module_base("libil2cpp.so");
if (base_addr != 0 && il2cpp_handle != nullptr) {
break;
}
}

然后调用 il2cpp 源码中的 il2cpp_domain_get_assemblies 获取所有 assembly 列表,遍历找到 Assembly-CSharp (大多数情况下游戏逻辑都在这里面,部分通用库会叫其他名字,需要自己修改

1
2
3
4
while(strcmp((*assembly_list)->aname.name, "Assembly-CSharp") != 0){
LOGD("Riru-hook: Assembly name: %s", (*assembly_list)->aname.name);
assembly_list++;
}

找到后再调用 il2cpp 源码中的 il2cpp_assembly_get_image 获取 image,获取后通过 C# 级的命名空间和类名取得 Il2CppClass

1
Il2CppClass* clazz = il2cpp_class_from_name(image, "NameSpace", "ClassName");

然后同样是通过 il2cpp 源码中的方法获取目标方法的地址

1
il2cpp_class_get_method_from_name(clazz, "MethodName", 1)->methodPointer

得到方法地址后,就可以调用 Dobby 进行被动 hook 注册了。

最后就是写我们自己的 hook 函数。比如我要 hook 的函数从 IDA 中看到是

1
2
3
4
5
System_String_o *__fastcall NameSpace_Class__MethodName(
ClassName_o *this,
System_String_o *version,
const MethodInfo *method
)

如果我想要获取返回值并打印出来,那么需要把返回值、参数一一对应起来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
cSharpString* hook(void* instance, cSharpString *src, void* methodInfo){
if(backup == nullptr){
LOGE("Riru-hook: backup DOES NOT EXIST");
}
LOGW("Riru-hook: ====== MAGIC SHOW BEGINS ======");
// FIXME: It's time to do some magic!

// 原始调用
cSharpString* r = backup(instance, src, methodInfo);
LOGE("Riru-hook: got result at %lx", (long)r);
LOGE("Riru-hook: length is %d", r->length);

// 由于 c++ 必须在字符串最后添加\0,故需要+1
char* str = (char*)malloc(r->length + 1);
memset(str, 0x00, r->length + 1);
// 每个字符占2字节
for (int i = 0; i < r->length; i++) {
strcpy(str + i, r->buf + i * 2);
}
LOGE("Riru-hook: string is %s", str);
free(str);

LOGW("Riru-hook: ====== MAGIC SHOW ENDS ======");
return r;
}

这里的 LOGE 只是我为了使结果更显眼,并不是 error。

函数的参数、返回值中我们不关心的值直接设置为 void* 即可,如果有我们需要用到的,则需要我们自己构建自定义结构体来使其与内存中的真实结构相对应,比如这里的 cSharpString

1
2
3
4
5
6
struct cSharpString {
size_t address; // size_t 在 arm64 中占8字节
size_t nothing;
int length;
char buf[4096];
};

如果不清楚,可以直接 dump 内存进行查看。这里需要发挥自己的想象力进行猜想。如果不清楚某一段内存的值是什么,直接在结构体中设置与其同样字节的占位符即可,直到我们需要的数据的地址 - 类基址 = 占位符字节 即可。

写完后,直接 build 生成 zip,丢进手机用 Magisk Manager 从本地安装模块,重启手机,打开游戏测试

image

Works like a charm!

总结一下

  • Frida,实时动态调试注入,强大。但毛病很多,经常会碰到一些玄学问题,并且由于出发点是调试,同时如果目标应用有反调试的话就没戏。

  • Xposed,框架本身只能 hook Java 层,native 层可以实现但十分复杂。

  • In-line Hook,字节码层面 hook,理论上可以做任何想到的事,但学习成本巨大。

  • Riru,直接 native 层 hook,只要找到目标库句柄和函数名就能手动调用该方法。配合 Dobby,能实现被动 hook。缺点是每次修改都需要重新构建、传手机、安装、重启。

References

 Comments
Comment plugin failed to load
Loading comment plugin