type
status
date
slug
summary
tags
category
icon
password
本篇文章全部基于Android15开发,只讨论hook java方法。对于native的hook以后再补充。这一hook框架被命名为GirlHook,因为是本人遭受感情打击的时候编写的。
1.Hook原理
1.1 Art
在 Android 15 上,所有 App 默认都运行在 ART(Android Runtime)上。这是 Android 系统的核心机制之一,自从 Android 5.0(Lollipop)开始,ART 就完全取代了原来的 Dalvik 作为系统默认运行时。
- ART 是什么?
Android Runtime(ART)是 Android 的应用运行环境,负责将 Java/Kotlin 写的代码(.dex 字节码)转换为机器码并执行。
- 是否可以不用 ART?
不可以。从 Android 5.0 开始,Dalvik 已被移除,ART 是强制性的运行时环境,应用无法选择其他运行机制(除非你运行的是完全自定义的系统,脱离了 AOSP 的范畴)。
- 即便是 Native App?
是的,即使 App 主体是用 C/C++ 写的,只要有用到 Java 层(Activity、Service、Context 等),也依赖 ART 运行时。例如你使用
JNI
调用 Java 方法,那这些 Java 方法还是由 ART 管理的。1.2 如何进行hook
首先来了解一下Art是怎么调用Java函数。有一个关键的结构体,ArtMethod。java方法在ART虚拟机中以ArtMethod表示,其中entry_point_from_quick_compiled_code_保存的是方法的入口地址,在类加载的LinkCode()函数中设置入口地址。
平时我们用的env->CallStaticVoidMethod是一个函数指针,经验证实际指向jni_internal.cc::CallStaticVoidMethodV()函数,会进一步调用到ArtMethod的Invoke()方法。Invoke()方法最终调用到汇编代码art_quick_invoke_stub_internal,汇编又进一步跳转到ART_METHOD_QUICK_CODE_OFFSET_32偏移,根据定义可知,该偏移正是ArtMethod对象中的成员变量entry_point_from_compiled_code_,由此真正开始进入java方法。
也就是说,主要分为两种情况,当 ART 未初始化完成或者系统配置强制以解释模式运行,此时则进入解释器;另一种情况是有 native 代码时,比如 JNI 代码、OAT 提前编译过的代码或者 JIT 运行时编译过的代码以及代理方法等,此时则直接跳转到 invoke_stub 去执行。
对于解释执行的情况,也细分为两种情况,一种是真正的解释执行,不断循环解析 CodeItem 中的每条指令并进行解析;另外一种是在当前解释执行遇到 native 方法时,这种情况一般是遇到了 JNI 函数,这时则通过
method->GetEntryPointFromJni()
,跳转到的就是data字段的地址。而对于快速执行的模式是跳转到 stub 代码,以非静态方法为例,该 stub 定义在 art/runtime/arch/arm64/quick_entrypoints_arm64.S 文件中,大致作用是将参数保存在对应寄存器中,然后跳转到实际的地址执行。x0
的值保存着 ArtMethod 的地址,实际跳转的是对于 ArtMethod 中的entry_point_from_quick_compiled_code_。 简单来说,所有方法都会进入void* entry_point_from_quick_compiled_code_,根据这到底是个什么方法,再经过不同的管道来到data。data是函数,但不一定是什么类型。
注释也是这么说的。
方法类型 | data_ 表示含义 |
native 方法 | 注册的 JNI 函数指针 或 延迟解析器函数 |
resolution 方法 | 用于解析目标方法的函数指针 |
conflict 方法 | IMT 冲突表指针 |
抽象/接口方法 | 唯一实现方法的指针(如果已知) |
代理方法 | 对应的原始方法指针 |
default conflict method | nullptr |
其他普通方法(非 native) | AOT 阶段是 offset,运行时是指向 code item 的指针 |
所以可以这样进行hook:因为hook框架在native编写,hook后函数作为一个native函数,只需要让该方法当成native进行执行就行了,执行到我们的函数。而AccessFlag标记了这个函数的类型和其他一些信息,只要修改AccessFlag就可以让它变成一个native函数。
第一种方法是,直接用entry_point_from_quick_compiled_code_这个改为自己的native函数地址,那么不需要经过管道,直接就进来了。缺点是需要自己处理参数。第二个方法,也是frida目前在用的方法,是把entry_point换成转到jni方法的管道,然后在data写入自己的函数即可,这样自己的函数被当成jni native方法调用,还能接收到env和this/class指针。第一种方法我尝试过了,是可以跑通的,并且也一样可以调用原函数(我没试过解析参数,所以只调用了静态方法)。这里重点讲一下第二种方法,也就是改成管道,然后改JNI,再改flag。直接在JNI函数拿到env和this指针,来调用动态方法。
1.3 所需的数据结构
这部分就好像在做游戏逆向一样:想办法用已有的能找到的方法,去拿更多需要用的方法;知道某些类的某些字段的偏移,以便直接取出来用。
首先我们需要ArtMethod结构体,我们需要能拿到要hook的方法的对应这个实例,才能替换入口点,进行hook。
其次,需要知道我们要用到的管道函数的地址,以便把所有entry都给替换成到jni的管道。
1.3.1 JavaENV与JavaVM
因为我们希望实现类似Windows下的hook效果,即注入一个DLL,对进程函数进行hook;那么Linux下,就是注入一个SO,对进程的函数进行Hook。因为我们需要有用到JavaENV,以及Java_vm这两个,才能实现上面我们提到的功能;有这两个数据结构,才能访问java方法。
JavaVM
(Java Virtual Machine)作用:全局的 JVM 实例句柄
JavaVM*
是 代表整个虚拟机的接口,你可以通过它获取JNIEnv*
。
- 在 Android 中,通常每个进程只有一个
JavaVM
实例(除非你使用了多虚拟机架构,通常不存在)。
典型用途:
- 在线程中获取 JNIEnv:
因为
JNIEnv*
是线程绑定的(thread-local),不能跨线程使用,所以如果你需要在 非 Java 线程中使用 JNI,就必须先通过 JavaVM
获取当前线程的 JNIEnv*
。示例:
重要:
- 它是线程安全的,可以跨线程使用。
- 通常在
JNI_OnLoad
时系统会把它传给你一次,也可以自己想办法获取。
二、
JNIEnv
(Java Environment)作用:每个线程的 Java 运行上下文,用来调用Java接口
JNIEnv*
是一个 线程私有的结构体,用于访问 JVM 的功能,比如:- 查找类
- 调用 Java 方法
- 创建 Java 对象
- 抛出异常
- 操作数组、字符串等
典型用途:
在大多数 Native 方法实现中看到的都是这个指针:
注意事项:
- 不能跨线程使用!
- 它只能在创建它的那个线程中使用,所以,最好也不要缓存env
这里创建一个类,直接用这个类来方便的获取jvm和env
1.3.2 ArtMethod拿法、结构探测
在低版本的Android当中,拿ArtMethod是非常简单的,只需要methodID即可。要拿到methodid,首先要有拿到class,还要知道函数名,函数签名等等,是非常麻烦的。这里我们写一些工具函数。
这是一个获取类的工具函数。我们必须拿到currentApplication才能获取对应的classloader。因为JNI中的env其实是ENVEXT,不能用来获取类,是获取不到app中的java类的。先调用ActivityThread.currentApplication(),拿到env,再用env获取classloader,这时候的classloader是可以找到app中的java类的。那么调用classLoader.loadClass即可拿到对应类。因为如果调用
ClassLoader.loadClass("xxx.YourClass")
时,这个类 已经被加载过了,那么它的行为如下:不会重复加载,而是返回已经加载的类对象。 拿到了class之后,需要再根据类的名称、签名去找类,这是非常麻烦的,因为签名是很难整的。我们可以模仿frida的写法,直接遍历类的所有方法,找到我们所需要的就行了,不用非得写签名。我只写了匹配名称和动态/静态,没写匹配参数的重载。这个可以后续再说。主要原理就是通过
这些函数,来获取和目标匹配的方法就行了。这些都是系统自带的类里面的函数,不需要loader去getclass,直接env→findclass就行了,然后getmethodID,最后callobjectMethod,拿到结果,比对后返回。注意可以顺便根据参数类型,来写一个shorty,方便后续调用。shorty是返回值简称加参数类型简称,比如返回值boolean,参数为一个int的,shorty就是ZI。
关键是怎么通过methodID去拿到ArtMethod。基本拿到ArtMethod我们就已经可以替换方法了,这是非常关键的一步。刚才我们也说了,低版本可以直接强转,高版本不行。直接在IDA中打开libart.so,搜索methodid,可见DecodeMethodId函数。在art/runtime/jni/jni_id_manager.cc里面有实现
那么这个DecodeGenericID又是什么
原理就是,如果地址%2==0,是偶数,那肯定是对齐出来的指针,直接强转就行了;否则需要经过它去GetGenericMap去找指定index的,那这个是比较麻烦的,能直接调用这个函数是最好的。像游戏逆向一样,直接调用函数这个再熟悉不过了,只要拿到函数地址,当成函数指针,调用即可。那么这里用到了一个注入器,TInjector里面提供的代码,利用ELFIO可以在指定的模块里查找对应符号的函数地址。它拿这些代码是为了直接获取env和jvm。这个我们主线程也会用到,因为我们后续也想可以把so注入其他进程使用。
代码如下,可以全部封装起来,便于使用。我是全都封装在一个tool命名空间里。其中对get_address_from_module函数进行了一点小修改,添加了一个不是函数也可以通过的功能,并且把原先检测是否落在段内给改成是否落在image内即可。不然找不到BSS段的变量。这和BSS的结构有很大关系,BSS只有so动态加载的时候才会分配内存并填充0。直接读取BSS段的大小是不正确的。
所以,我们可以获得decode函数。

显然,DecodeMethodID需要两个参数,第二个是methodID,这个我们知道,第一个是jniIdManager,也就是this指针,这个参数我们没有。查看JniIdManager类的所有函数,找找交叉引用。发现这个地方

所以就明白了,这个实例可以通过art::Runtime::instance_拿到,是偏移0x270处。这里是一个指向实例的指针。读出改处指针+0x270即可。拿art::Runtime::instance_的方法与decodemethod指针一样。

然后就拿到了jniIdManager,传入methodID即可获取对应的ArtMethod*这一指针了。目前我们已经知道,需要替换ArtMethod里面的什么字段;但问题是,ArtMethod这一结构体在Native层开发中并没有对应的头文件,需要自己对照源码找偏移。不同的Android版本偏移也可能不同。但字段都是那么几个,只要知道各个字段之间的差值(根据Android版本来硬编码),找到一个固定的锚点,就行了。那这也是frida的做法。观察前文我们所提到的ArtMethod结构,可以利用access_flags_字段作为锚点。具体来说,我们可以找到某一个系统函数的ArtMethod,系统函数的access_flags_是已知的(硬编码),遍历该结构所有字段即可找到锚点。
比如这里利用getElapsedCpuTime,它的flag是kAccPublic | kAccStatic | kAccFinal | kAccNative,遍历所有字段,&0xffff取低位,与预期相同即可。
1.3.3 获取管道函数地址
接下来可以进行hook。为了避免自己解析参数,我们采用trampoline跳转的形式。

所以接下来还要拿到trampoline。trampoline的获取有几个方法。
- 找到已知方法拿trampoline:因为trampoline函数的地址都是共享的,都用的同一个函数,所以可以利用已知的系统方法,找一个已知的native jni,读出entrypoint即可(我们hook java函数只需要
quickGenericJniTrampoline
这一个管道。)
- 解析数据结构获取全部trampoline。trampoline全部存在classlinker这个结构里,这个结构在Runtime里有指针。
这里我们采用第二种方法,也方便了我们获取全部的trampoline。
Runtime的实例地址我们已经有了,还需解析出classlinker指针所在offset。可以通过两个方法找,先看图

从java_vm_找是可以的,也可以利用我们刚找到的jni_id_manager。前者的获取方法放在章节1.4来讲。
还是遍历所有字段,找到后减去硬编码的偏移,拿到class_linker_。然后接着解析class_linker_。要在classlinker结构内找到trampoline也不简单,但是这里有一个intern_table字段,而runtime里也有,只需要比对,找到二者相等,那么下方就是trampoline了

1.4 进行Hook
1.4.1 hook安装
将ArtMethod的entry替换为跳转到jni的管道,jni入口替换为自定义的函数。如果系统有interpreter字段,还要给这个换成interpreter到jni的管道。同时,修改flag,让系统认为这是一个native函数。特别是加上
kAccCompileDontBother
防止函数调用次数多,被判定为热点函数后编译。
1.4.2 参数传递
首先是寄存器传参,这个比较简单,符合arm64的传参规定,用满x0-x7, v0-v7,然后开始用栈。x0,x1分别应该是env与this或class指针,后面直接用参数填满,直接从寄存器取,多余的再考虑栈。因为函数头一般不会处理v寄存器,所以可以先把关键的内容拿完,再把v寄存器的拿出来。

从汇编来看,x29寄存器存储了sp,那么可以想办法手动从栈上取参数。
看看栈上是怎么传递参数的。反汇编一下,看到先把sp-0x20后,存储x29, x30,然后在sp+0x10处存储x28,接着把sp存储进X29这个栈指针寄存器。我们可以在函数头读取x29寄存器的值,然后+0x20,是存放参数的位置。当然,这和函数序言有关,不过一般情况下,可以硬编码为+0x20。可以直接把这个当成栈参数列表指针,后续手动parse即可。



比较好的一点是,参数全部对齐栈进行传递,即使传递的是一个char,也会对齐8字节。这里用一个char参数传递了一个字符H,也是对齐了8位。这样我们处理起来会很轻松。

目前还有一个问题,即无法判断当前函数到底来自于哪个hook,那就无法获取原函数信息,并调用回去。只凭借一个env和this/class指针,要获取原方法信息,太麻烦了。既然我们进行hook的时候是知道的,那么可以缓存起来;我用了一个index来表示这是第几个hook,存储进一个全局的Vector,则执行到这里,根据hookid可以拿到原函数信息。问题在于:该函数无法得知自己是第几号hook。所以这里我想了办法,加了一层管道函数。给每个hook动态的生成一个管道,动态生成的管道硬编码id,然后来调用我们的函数。
利用这个工具函数,传入hookID,和要跳转到的地址,生成硬编码的管道函数。主要作用是保存x0,x1(env和this/class),把硬编码的hookid放入x0。读取硬编码的handler地址进入x1,然后跳转到x1,来到handler。

此后,为了避免函数序言破坏我们从栈上取env和this,也就是方便起见,我们再利用一个没用的寄存器,来存储hookid,把env和this/class再放回x0,x1,然后跳转到固定的handler地址。这是一个裸函数,是第二层管道。
这里利用x15来存储hookid,接下来正式进入handler,handler就能正常接收到所有参数,同时x15里面还就是hookid。为了避免编译器生成的代码对x15寄存器产生破坏,必须把读取x15寄存器内联汇编放在函数开头。这也是上面截图中所包含的。其实x9-x15都可以,因为这些是临时的寄存器,调用者使用完不保证值不变,不能保值跨函数调用。实测x9有可能会被编译器用到,x15则不会。这样可以凭空拿到hookid。

1.4.3 调用原方法
原方法的调用是比较麻烦的。native层要调用原方法,一个是可以用反射,一个是去手动调用ArtMethod::Invoke函数。前者我尝试了,在hook函数执行的过程中,修改ArtMethod并反射调用,会直接导致崩溃,这是不允许的行为;后者则是可以用的。这块调用原方法耗费了我大量的时间才摸索出来。
因为我们hook后的方法,可以被当作是一个普通的native jni函数,那么env和this/class指针都是现成的,可以直接用。这里面有一些坑。
坑1:重新找method
静态方法不找也不会崩溃,动态方法据GPT说,是有可能会被移动的,至少我添加了重新找之后,确实是稳定多了。
坑2:复制一份出来恢复hook
如果直接存ArtMethod指针,直接在ArtMethod上恢复hook然后调用,崩溃的概率非常之大,频繁崩溃。只能分配一块新的内存,然后整个拷贝过来,把新的内存去Invoke来用。因为ArtInvoke的时候,会改动ArtMethod里面的一些字段,具体有什么被改了,我没研究。但如果拷贝出来一个,相当于凭空调用一次,稳定性有所提升。
坑3:GC回收
有时候,INVOKE之前GC已经不知道把什么关键内容给垃圾回收了,导致INVOKE即崩溃。最终采用scoped_gc_critical_section函数,在栈上直接开个buffer创建个对象,用完destroy即可。大大提升了稳定性。低频调用(200ms一次)的情况下测试了10个小时没崩溃。
为了不让GC回收还有另外一种方法,就是创建localref或者globalref。那么对thiz指向的对象进行创建,结束后再删除即可。不过,我这里测试没有criticalsection稳定。我这里测试的稳定性:
localref < globalref < gc_criticalSection
坑4:动态方法的参数传递
查了大量的资料,来看到底Invoke是怎么传递参数的,都没有找到比较准确的回答。因为ArtMethod::Invoke是这么实现的:
传入的参数是uint32_t*,这是参数列表,还传入了一个uint32_t的参数size。
让人不禁疑惑,为什么是以uint32_t*传入?uint32_t是不是指这个数组的长度?那我要传入64位数作为参数,应该怎么办?静态方法还好说,那动态方法必须传入一个this指针,这是32位数组,怎么能传入64位指针呢?
接下来,仔细分析一下它内部是怎么处理参数列表的。首先来看动态的函数,是怎么取出this指针的。把args[0]当成mirror::Object,作为receiver(接收结果的java对象)。然后调用AsMirrorPtr。那么关键就是要知道,this和StackReference类型有什么关系。
找到StackReference的定义
继承了CompressedReference。
这里我借用别人的一张图和一段话:

右上角的mirror::Object对象代表一个new出来的Object对象。new出来的是一个指向这个对象的指针。
ART中不允许直接保存这指针,所以弄了一个ObjectPtr数据结构,这个数据结构里reference_(uintptr_t类型,可以存下一个指针,不管是64位还是32位)。
ART也不直接使用ObjectPtr,而是使用ObjectReference(包含CompressedReference、StackReference)。这三个Reference结构只有一个reference_(uint32_t类型,32位长,特别注意)。
ObjectReference中reference_是由ObjectPtr的reference_强制数据类型转换而来。在64位机器上,指针也是64位,而ObjectReference只有32位长,岂不是会丢失信息?确实有这个问题,但是没关系。因为丢失的那32位数据都是0。这里涉及到JVM在64位设备上设计的考虑。64位设备上,分配的对象的地址是64位长,但如果我要保存这些对象地址的话,所需的一个变量也需要64位长。假如我们分配了1万个对象,光存储这1万个对象的地址就需要64万/8个字节。这比32位系统多了1倍的存储空间。所以,JVM在64位设备上做了一些优化。实际上还是使用32位数据来存储这个64位指针。而这个64位指针的高(或低)32位为0(或者为其他什么别的固定值)。另外,为了兼容32和64位,所分配对象的大小必须是8字节的整数倍。所以,基于这种设计的JVM不能支持32GB(8*(2<<32))以上的内存空间。
最下边的是Handle家族。它的reference_指向一个StackReference指针。
所以,对于动态方法,第一个参数直接传this读出来的内容就行了。确实是32位。但argsize设置为1还是崩溃,这是怎么回事?
接着,我给静态方法加了一个参数,并尝试传参数调用(静态方法不需要this)。发现,argsize为1,崩溃,读不到参。我试着把args数组变得非常大,也给一个比较大的argsize。我发现正好为4的时候,能正确执行。
argsize代表的是所有参数占用字节数。
如果要传递超过uint32_t的内容,比如long,那么要拆成两个Uint32_t,可以理解成占两个槽位

OK,hook框架完成,这里可以带参调用了。并且稳定性也还不错。高频调用基本上10ms一次,偶发崩溃,触发概率不高,有时候半小时都不崩。低频调用200ms一次挂了一晚上10小时左右,没事。
2. 功能
2.1 Lua桥
Lua桥采用Sol2。可以这样理解,Sol2本质上是对lua接口的封装,并不包含lua核心。因此lua需要自己交叉编译Andoird AArch64的静态库,然后链接进去。
CMakeList.txt里面,要这样写,一个是brew安装的lua的头文件的位置,一个是lua静态库的位置。静态库得手动编译一下。
给lua源码clone下来,Makefile改一下,


然后,用mac的llvmar编译器。这是build.sh
就能拿到静态库了。
Lua桥的使用如下,可以绑定自定义的函数,然后在lua脚本即可调用。所以,之后这个so只需要加载用户脚本,就可以执行so内部的hook,修改等函数。
那么logcat也就做到了LUA和C++都能输出。

- Author:Lynnette177
- URL:https://next.lynnette.uk/article/girlHook
- Copyright:All articles in this blog, except for special statements, adopt BY-NC-SA agreement. Please indicate the source!
Relate Posts