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函数。
notion image
显然,DecodeMethodID需要两个参数,第二个是methodID,这个我们知道,第一个是jniIdManager,也就是this指针,这个参数我们没有。查看JniIdManager类的所有函数,找找交叉引用。发现这个地方
notion image
所以就明白了,这个实例可以通过art::Runtime::instance_拿到,是偏移0x270处。这里是一个指向实例的指针。读出改处指针+0x270即可。拿art::Runtime::instance_的方法与decodemethod指针一样。
notion image
然后就拿到了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跳转的形式。
notion image
所以接下来还要拿到trampoline。trampoline的获取有几个方法。
  1. 找到已知方法拿trampoline:因为trampoline函数的地址都是共享的,都用的同一个函数,所以可以利用已知的系统方法,找一个已知的native jni,读出entrypoint即可(我们hook java函数只需要quickGenericJniTrampoline这一个管道。)
  1. 解析数据结构获取全部trampoline。trampoline全部存在classlinker这个结构里,这个结构在Runtime里有指针。
这里我们采用第二种方法,也方便了我们获取全部的trampoline。
Runtime的实例地址我们已经有了,还需解析出classlinker指针所在offset。可以通过两个方法找,先看图
notion image
从java_vm_找是可以的,也可以利用我们刚找到的jni_id_manager。前者的获取方法放在章节1.4来讲。
还是遍历所有字段,找到后减去硬编码的偏移,拿到class_linker_。然后接着解析class_linker_。要在classlinker结构内找到trampoline也不简单,但是这里有一个intern_table字段,而runtime里也有,只需要比对,找到二者相等,那么下方就是trampoline了
notion image

1.4 进行Hook

1.4.1 hook安装

将ArtMethod的entry替换为跳转到jni的管道,jni入口替换为自定义的函数。如果系统有interpreter字段,还要给这个换成interpreter到jni的管道。同时,修改flag,让系统认为这是一个native函数。特别是加上kAccCompileDontBother 防止函数调用次数多,被判定为热点函数后编译。
notion image

1.4.2 参数传递

首先是寄存器传参,这个比较简单,符合arm64的传参规定,用满x0-x7, v0-v7,然后开始用栈。x0,x1分别应该是env与this或class指针,后面直接用参数填满,直接从寄存器取,多余的再考虑栈。因为函数头一般不会处理v寄存器,所以可以先把关键的内容拿完,再把v寄存器的拿出来。
notion image
从汇编来看,x29寄存器存储了sp,那么可以想办法手动从栈上取参数。
看看栈上是怎么传递参数的。反汇编一下,看到先把sp-0x20后,存储x29, x30,然后在sp+0x10处存储x28,接着把sp存储进X29这个栈指针寄存器。我们可以在函数头读取x29寄存器的值,然后+0x20,是存放参数的位置。当然,这和函数序言有关,不过一般情况下,可以硬编码为+0x20。可以直接把这个当成栈参数列表指针,后续手动parse即可。
notion image
notion image
notion image
比较好的一点是,参数全部对齐栈进行传递,即使传递的是一个char,也会对齐8字节。这里用一个char参数传递了一个字符H,也是对齐了8位。这样我们处理起来会很轻松。
notion image
目前还有一个问题,即无法判断当前函数到底来自于哪个hook,那就无法获取原函数信息,并调用回去。只凭借一个env和this/class指针,要获取原方法信息,太麻烦了。既然我们进行hook的时候是知道的,那么可以缓存起来;我用了一个index来表示这是第几个hook,存储进一个全局的Vector,则执行到这里,根据hookid可以拿到原函数信息。问题在于:该函数无法得知自己是第几号hook。所以这里我想了办法,加了一层管道函数。给每个hook动态的生成一个管道,动态生成的管道硬编码id,然后来调用我们的函数。
利用这个工具函数,传入hookID,和要跳转到的地址,生成硬编码的管道函数。主要作用是保存x0,x1(env和this/class),把硬编码的hookid放入x0。读取硬编码的handler地址进入x1,然后跳转到x1,来到handler。
notion image
此后,为了避免函数序言破坏我们从栈上取env和this,也就是方便起见,我们再利用一个没用的寄存器,来存储hookid,把env和this/class再放回x0,x1,然后跳转到固定的handler地址。这是一个裸函数,是第二层管道。
这里利用x15来存储hookid,接下来正式进入handler,handler就能正常接收到所有参数,同时x15里面还就是hookid。为了避免编译器生成的代码对x15寄存器产生破坏,必须把读取x15寄存器内联汇编放在函数开头。这也是上面截图中所包含的。其实x9-x15都可以,因为这些是临时的寄存器,调用者使用完不保证值不变,不能保值跨函数调用。实测x9有可能会被编译器用到,x15则不会。这样可以凭空拿到hookid。
notion image

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。
这里我借用别人的一张图和一段话:
notion image
右上角的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,可以理解成占两个槽位
notion image
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改一下,
notion image
notion image
然后,用mac的llvmar编译器。这是build.sh
就能拿到静态库了。
Lua桥的使用如下,可以绑定自定义的函数,然后在lua脚本即可调用。所以,之后这个so只需要加载用户脚本,就可以执行so内部的hook,修改等函数。
那么logcat也就做到了LUA和C++都能输出。
notion image
 
Relate Posts
一加11 内核、ROM爆改+脱壳机 LineageOS 22.2 Android 15
Lazy loaded image
小红书shield Chomper模拟
Lazy loaded image
iOS网易新闻登录算法逆向
Lazy loaded image
iOS典型反调反越狱app分析
Lazy loaded image
iOS免越狱hook与patch框架和免越狱dylib注入打包
Lazy loaded image
iOS逆向——某跑步软件的文件上传
Lazy loaded image
对rwProcMem33的尝试修补一加11 内核、ROM爆改+脱壳机 LineageOS 22.2 Android 15
Loading...
Lynnette177
Lynnette177
建议开着梯子访问站点。图片是直接从Notion获取的,不开梯子容易看不见图片。
Latest posts
写一个Android Hook小框架
2025-6-23
一些macos常用软件破解记录
2025-6-22
iOS典型反调反越狱app分析
2025-6-22
iOS网易新闻登录算法逆向
2025-6-22
小红书shield Chomper模拟
2025-6-22
一加11 内核、ROM爆改+脱壳机 LineageOS 22.2 Android 15
2025-6-22
Announcement
🎉2024.6.9 上线🎉