Android jni 方法 hook 的实现方案
简介
本文主要是简述一下 jni 方法的调用流程,然后讨论下对jni 方法hook的实现方案。
JNI 即 Java Native Interface,作为Java代码和native代码之间的中间层,方便Java代码调用native api以及native 代码调用Java api。
以 Android 上Java 代码启动线程为例,调用 Thread.start
方法时,会调到 nativeCreate
进而调用到他的 native peer Thread_nativeCreate
,最后创建相应的 pthread。
那么我们说的jni hook主要做的就是可以修改 Java native method 的 native peer,以上面创建线程为例,hook前,nativeCreate
的 native peer 是 Thread_nativeCreate
,通过jni hook,我们可以将native peer改为我们指定的 Thread_nativeCreate_proxy
,这样后面调用 nativeCreate
就会执行到 Thread_nativeCreate_proxy
。
要实现 jni hook,主要需要做2点:
- 修改 native peer 为我们指定的 proxy 方法
- 获取原来的方法地址,因为很多时候在proxy方法中都需要调用原方法
在实现hook之前,我们先来看看jni方法的链接和调用过程。
jni 方法的链接
jni 方法链接有两种方式:
- 通过
RegisterNatives
主动注册 - 按照 jni 的规范命名,由虚拟机在运行时自动查找和绑定,Java native method 和 jni native method的命名映射规范可以参考:Resolving Native Method Names
主动注册的流程
以Android 12 的代码为例:RegisterNatives
的实现是在 ClassLinker
中,会通过ArtMethod::SetEntryPointFromJni
将我们的jni方法地址存储到 ArtMethod 的 data_
字段中。
struct PtrSizedFields {
// Depending on the method type, the data is
// - native method: pointer to the JNI function registered to this method
// or a function to resolve the JNI function,
// - resolution method: pointer to a function to resolve the method and
// the JNI function for @CriticalNative.
// - conflict method: ImtConflictTable,
// - abstract/interface method: the single-implementation if any,
// - proxy method: the original interface method or constructor,
// - other methods: during AOT the code item offset, at runtime a pointer
// to the code item.
void* data_;
// Method dispatch from quick compiled code invokes this pointer which may cause bridging into
// the interpreter.
void* entry_point_from_quick_compiled_code_;
} ptr_sized_fields_;
从注释中也可以看到对于Java native 方法,data_
里面存储的是jni方法地址(或者是查找目标jni方法的stub方法地址,对应于上面提到的第二种方式)
隐式注册的流程
“隐式注册”是指不用我们主动调用RegisterNatives
,而是由虚拟机自己去查找jni方法的符号地址。而这个查找jni方法符号的辅助方法的地址也是存储在 ArtMethod 的 data_
中的。这个赋值逻辑是在方法链接的过程中进行的。
当加载一个类的时候,通常会走以下几个步骤:
- loading:寻找指定类的字节码,并按照 class file format 进行解析
- linking:将从字节码中加载的数据处理成虚拟机运行时需要的数据结构,主要有以下几步:
- verification:验证字节码的正确性,发现问题的话会抛出 VerifyError
- preparation:为类(或接口)创建静态字段并初始化为默认值(或者 ConstantValue Attribute指定的值,如果有这个属性的话)
- resolution:将符号引用转换为内存中对应数据结构的引用(在字节码中,比如对某个类的引用,实际是常量池中 CONSTANT_Class 对应的索引)
- initialization:执行
<clinit>
在linking阶段,也会对类中方法体(code attribute)进行链接,具体代码是在 class_linker.cc 的 LinkCode中,下面摘一下为data_
赋值查找jni方法符号的辅助方法的逻辑(Android 12代码为例):
static void LinkCode(ClassLinker* class_linker,
ArtMethod* method,
const OatFile::OatClass* oat_class,
uint32_t class_def_method_index) REQUIRES_SHARED(Locks::mutator_lock_) {
// ...
if (method->IsNative()) {
method->SetEntryPointFromJni(
method->IsCriticalNative() ? GetJniDlsymLookupCriticalStub() : GetJniDlsymLookupStub());
// ...
}
}
可以看到对于Java native method,会把 data_
字段赋值为 GetJniDlsymLookupStub
返回的查找jni方法符号地址的stub地址。(备注:本文不考虑 FastNative & CriticalNative,这类方法实现要求快速、不能阻塞,CriticalNative限制会更多,所以实现一般都比较简单,还未遇到hook他们的需求)
jni方法符号查找的主要流程(Android 12为例):
art_jni_dlsym_lookup_stub
-> artFindNativeMethod
-> artFindNativeMethodRunnable
-> 1. 通过 class_linker.GetRegisteredNative 查看是否有其他线程已经完成了注册,如果有,直接返回(Android 12 之前的版本没有这个逻辑)
2. 调用 JavaVMExt::FindCodeForNativeMethod
-> FindNativeMethod
1. 根据 JNI 规范中定义的 Java native method 和 jni method 名称映射规范生成 jni_short_name & jni_long_name
2. 调用 FindNativeMethodInternal,通过 dlsym 查找符号
3. 将返回的符号地址通过 class_linker->RegisterNative 进行注册,下次就不必查找了
jni 方法的调用过程
- 从
ArtMethod::invoke
方法可以看到,对于 Java native method,调用会通过art_quick_invoke_stub
或者art_quick_invoke_static_stub
来进行,我们下面以static方法的流程来看 - arm64 架构上
art_quick_invoke_static_stub
是以汇编代码实现的,主要的工作:- 部分寄存器的暂存(比如 lr、fp等)
- 参数的预处理:对于 AACPS64 calling convention 参数是存放到 x0 ~ x7 中的,另外(hardfp)浮点参数 float、double 是存放到 s/d 寄存器中的,所以会根据参数类型进行分组
- 另外 jni 方法(非 CriticalNative)会增加 JNIEnv、jobject/jclass 参数,此处也会在栈上预留空间等
- 通过 blr 跳转到 ART_METHOD_QUICK_CODE_OFFSET_64 处执行,对应的地址是:art::ArtMethod::EntryPointFromQuickCompiledCodeOffset(art::PointerSize::k64).Int32Value() ,也就是 ArtMethod 的
entry_point_from_quick_compiled_code_
- jni 方法返回后,根据返回值的类型从x0、d0、s0从取出返回值
上面提到 Java native method 调用会跳转到 ArtMethod entry_point_from_quick_compiled_code_
所指的内存处执行,那么 entry_point_from_quick_compiled_code_
对应的代码是什么呢?
上面的 entry_point_from_quick_compiled_code_
就是在 linking 过程中赋值的,具体逻辑在 class_linker.cc LinkCode
中:
// ...
if (quick_code == nullptr) {
method->SetEntryPointFromQuickCompiledCode(
method->IsNative() ? GetQuickGenericJniStub() : GetQuickToInterpreterBridge());
else if (/*xxx*/) {
//...
} else {
method->SetEntryPointFromQuickCompiledCode(quick_code);
}
从上面的代码可以看到:对于 native method entry_point_from_quick_compiled_code_
赋的是:art_quick_generic_jni_trampoline
(quick_code 是 jit compiler生成的,对于native方法,他生成的跟 art_quick_generic_jni_trampoline 的功能应该是一致的)
现在我们继续看调用流程:
-
art_quick_generic_jni_trampoline
:这里主要做了以下几点:- 调用
artQuickGenericJniTrampoline
,这里会切换线程状态到kNative
,这个状态是gc安全的,也就是如果要触发gc的话,不需要suspend kNative 的Java 线程。另外会通过GetEntryPointFromJni
获取 jni 方法的地址(准确的说,这个地址可能是jni 方法的地址,也可能是负责查找目标jni方法的stub方法地址) - 通过 blr 到上面
GetEntryPointFromJni
的地址实现目标jni方法调用 - 调用
artQuickGenericJniEndTrampoline
来处理frame,以及将线程状态切换到kRunnable
等
- 调用
上面提到从 GetEntryPointFromJni
获取的jni 方法地址,也就是 ArtMethod 中的 data_
字段。
jni 方法链接&调用小结
这里简单总结一下上面jni方法链接和调用的过程(Android 12为例):
- 在
class_linker.cc
的LinkCode
中将 jni entry point:ArtMethod::data_
设置为 查找目标jni方法符号的stub地址:art_jni_dlsym_lookup_stub
- 在调用java native method时,会跳到
ArtMethod::data_
地址处执行- 如果在调用之前有通过
RegisterNatives
主动注册jni方法地址的话,那么执行的就是jni方法 - 如果调用之前没有主动注册的话,那么此次
data_
处对应的就是查找jni方法的stub地址,该方法会按照Java native method 和 jni native method的命名映射规范:Resolving Native Method Names来查找目标jni方法符号的地址- 如果没有找到,则抛出
UnsatisfiedLinkError
- 如果找到了,则将其地址存入
ArtMethod::data_
中(后续调用就不必再查找了,流程跟plt延迟绑定很像),然后再跳转到该目标地址执行
- 如果没有找到,则抛出
- 如果在调用之前有通过
无需调用原方法的jni hook实现
如果不需要调用原方法,那么jni hook的实现非常简单:直接通过RegisterNatives
重新注册一下就好了。
这个方案有一个小点需要注意一下:对于fast native,重新注册之前要先去掉access_flags_
中的fast native标志位,否则可能会crash。
以Android 8.0 为例,可以看到首次注册时会向access_flags_
中添加fast标志,如果再调一次,在CHECK(!IsFastNative()) << PrettyMethod();
处就会出错,所以要先清除对应的标志位。
const void* ArtMethod::RegisterNative(const void* native_method, bool is_fast) {
CHECK(IsNative()) << PrettyMethod();
CHECK(!IsFastNative()) << PrettyMethod();
CHECK(native_method != nullptr) << PrettyMethod();
if (is_fast) {
AddAccessFlags(kAccFastNative);
}
//...
}
需要调用原方法的jni hook实现
跟上面的实现主要的不同就是要获取原方法的地址。这就要分2中情况:
- 原方法已经注册,也即
ArtMethod::data_
中的值就是原方法地址,读出来即可 - 原方法未注册,也即
ArtMethod::data_
中存储的是查找jni方法的stub地址,我们需要自己去查找原方法的地址
如何获取原方法地址
那对于一个指定的ArtMethod
,我们怎么判断data_
中存储的是原方法地址还是查找jni方法的stub地址呢?之前看过有2个“简化方案”:
- 不关心
data_
中存的什么,直接按照java native method 和 jni 方法命名映射规范去查找符号地址。
这个方案是有问题的,对于设计上主动通过RegisterNatives
注册的case,通常我们不会按照默认的映射规范去命名jni方法(方法名太长了),所以查不到。而且即使能查到,如果之前通过RegisterNatives
注册过,那么查到的也不是这个“原”方法。
- hook前先触发一下目标方法的执行,然后读取
data_
字段的值。
这个方法其实更不好,因为hook一个方法通常是不应该触发其执行的,这个不符合使用者的预期,而且比如我们是想通过hook来规避一个可能的crash,结果hook的时候先触发了他的执行,那不就。。。
那如何判断呢?
一个直观的想法:上面分析的时候提到:查找jni方法的stub符号是:art_jni_dlsym_lookup_stub
& art_jni_dlsym_lookup_critical_stub
那我们先获取这2个符号的地址,然后看data_
的值是否是其中之一就行了:
- 如果是,那就可以自己查找目标jni方法符号地址来获取原方法地址
- 如果不是,那
data_
的值就是原方法地址
然而有点麻烦的是art_jni_dlsym_lookup_stub
& art_jni_dlsym_lookup_critical_stub
这2个符号都没有导出:因此我们需要 section header table,symbol table,string table。他们不是运行时需要的,有可能被strip掉,即使没有也很可能没有map进内存。
在我自己的设备上测了一下,libart.so
没有strip掉上面的信息,并且该文件app可读,所以能查到到上面2个符号的地址(当前so的 load bias + symbol.st_value 即是目标符号在当前进程的虚拟地址),所以这个方法可行,但并不可靠,因为可能某些设备上的libart.so
是strip过的。
其实有更简单的方案:上面jni方法链接过程中提到:在LinkCode
的时候会统一将data_
字段赋值为查找jni方法的stub地址。因此我们可以在hook库中添加一个 java native method,并且不为其注册jni方法,那么它对应的ArtMethod中的data_
字段的值就是stub方法的地址。
如何查找jni方法地址
如果data_
中存储的值是查找jni方法的stub地址,那么原方法地址就需要我们自己查找:
- jni方法的名称是什么
- 如何根据方法名找到方法地址
获取jni方法名称可以有2种方案:
- 根据Resolving Native Method Names命名映射规范自己生成 jni short name & jni long name,这个方法简单可靠
-
libart.so
中导出了相关的符号,我们可以通过dlsym
获取其地址,然后调用即可。(只是Android 7.0开始引入了linker namespace,某些so 比如 libart.so 我们可能无法dlopen,这个时候就需要我们自己解析elf,然后根据 dynamic segment: PT_DYNAMIC, 动态符号表: DT_SYMTAB, 动态字符串表: DT_STRTAB, sysv hash 表: DT_HASH, gnu hash 表: DT_GNU_HASH 来查找符号地址。
- jni short name的符号:8.0以上:_ZN3art9ArtMethod12JniShortNameEv,以下:_ZN3art12JniShortNameEPNS_9ArtMethodE
- jni long name的符号:8.0以上:_ZN3art9ArtMethod11JniLongNameEv,以下:_ZN3art11JniLongNameEPNS_9ArtMethodE
在拿到jni方法名后,可以借助dlsym
来查找符号地址,如果是特殊的so无权限dlopen的话,可以像上面提到的自己解析elf获取地址。
怎么获取java native method对应的ArtMethod地址
- 对于Android 11及以上,art method的地址可以从
Executable.artMethod
获取:
public abstract class Executable extends AccessibleObject
implements Member, GenericDeclaration {
// ...
/**
* The ArtMethod associated with this Executable, required for dispatching due to entrypoints
* Classloader is held live by the declaring class.
*/
@SuppressWarnings("unused") // set by runtime
private long artMethod;
// ...
}
- 对于 Android 11以下的,art method 的地址就是
jmethodID
对应的值:env->FromReflectedMethod(javaMethod)
怎么获取 data_ 字段在 ArtMethod 中的偏移
不同版本 data_
字段在 ArtMethod 中的偏移可能不同,而且其他rom也可能有改动,那怎么获取其偏移呢?
我们可以在hook库中增加一个java native method:A,为其主动注册一个jni方法 B,那么可知 A 对应的 ArtMethod A'中的 data_ 的值为 B 的地址。然后我们可以从 A' 开始搜索,看偏移多少的值与B的地址相同,那么该偏移就是 data_ 在 ArtMethod 中的偏移。(有没有可能恰好ArtMethod开头的某个数据跟B的地址相同导致偏移计算错了呢?有可能,但这个可能性极低)
hook流程的整体概述
hook library初始化流程:
- 计算 ArtMethod 中 data_ 字段的偏移量(在低版本的Android中这个字段名不是data_,但不影响,我们是动态搜索目标字段的偏移,后面读写都是用的偏移量)
- 计算查找jni方法的stub方法地址:stubAddr
方法hook的流程:
- 从目标方法 ArtMethod 的data_字段中读出value:oldAddr
- 如果 oldAddr != stubAddr,那么原方法地址就是 oldAddr
- 如果 oldAddr == stubAddr,那么根据命名映射规范生成 jni short name & jni long name,然后通过dlsym(或者自己解析elf)查找符号地址,该值便是原方法地址
- 将新方法地址写入 data_ 中
- 通过
__builtin___clear_cache
刷新指令缓存