深入ART JNI函数解析实现

1,569 阅读14分钟

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!

引言

通过本文,你将会了解到JNI函数在ART虚拟机的调用机制,以及通过JNI Hook的中的时序注册问题出发,让读者了解到针对动态注册JNI函数的监听方式的实现。

JNI函数调用机制部分:我们能了解到一个JNI函数是如何被ART虚拟机进行解析,也就是ArtMethod是如何填充native函数实现,从而引出下一部分JNI Hook中时序引起的问题,这是方案的理论基础。

JNI Hook中时序引起的问题:时序问题是JNI函数在未被解析的情况下进行hook产生的问题,这部分我们针对时序问题提出了相对应的解决方案。

JNI函数调用机制

JNI函数是我们在日常开发中会经常遇到的函数,JNI函数可以把具体的实现放在native代码中,同时暴露一个对外的、以native(java)|external(kotlin)方法方便jvm语言的调用。那么一个普通方法与JNI方法调用在art有什么区别呢?这里我们是需要知道的,因为在JNI函数hook领域中,需要做一些不同的处理保证正常的函数调用,下面我们来看一下ART中方法调用的实现,我们知道,正常我们在java/kotlin编写的函数,都会在art世界中以ArtMethod 这个类对象表示,方法的调用就在ArtMethod::Invoke方法中

void ArtMethod::Invoke(Thread* self, uint32_t* args, uint32_t args_size, JValue* result,
                       const char* shorty) {
  ...

  Runtime* runtime = Runtime::Current();
   // 是否开启解释执行,注意以下几个判断条件,jni函数大部分情况下走下面,IsNative()会返回true
  if (UNLIKELY(!runtime->IsStarted() ||
               (self->IsForceInterpreter() && !IsNative() && !IsProxyMethod() && IsInvokable()))) {
    // 区分方法修饰,如果是静态方法EnterInterpreterFromInvoke不需要receiver,即this指针,非静态方法需要对象本身,同时参数上要多一个this指针      
    if (IsStatic()) {
      art::interpreter::EnterInterpreterFromInvoke(
          self, this, nullptr, args, result, /*stay_in_interpreter=*/ true);
    } else {
      mirror::Object* receiver =
          reinterpret_cast<StackReference<mirror::Object>*>(&args[0])->AsMirrorPtr();
      art::interpreter::EnterInterpreterFromInvoke(
          self, this, receiver, args + 1, result, /*stay_in_interpreter=*/ true);
    }
  }
    // 正常情况下jni函数走这里
    else {
    DCHECK_EQ(runtime->GetClassLinker()->GetImagePointerSize(), kRuntimePointerSize);

    constexpr bool kLogInvocationStartAndReturn = false;
    bool have_quick_code = GetEntryPointFromQuickCompiledCode() != nullptr;
    if (LIKELY(have_quick_code)) {
     ....
      // 根据类型不同,“蹦床”也不同
      if (!IsStatic()) {
        (*art_quick_invoke_stub)(this, args, args_size, self, result, shorty);
      } else {
        (*art_quick_invoke_static_stub)(this, args, args_size, self, result, shorty);
      }
      if (UNLIKELY(self->GetException() == Thread::GetDeoptimizationException())) {
        // Unusual case where we were running generated code and an
        // exception was thrown to force the activations to be removed from the
        // stack. Continue execution in the interpreter.
        self->DeoptimizeWithDeoptimizationException(result);
      }
     .....
  }

  ... 
}

ArtMethod::Invoke 其实是art调用方法中比较精髓的部分,他这里会按照当前方法的一些属性不同,比如方法是不是native方法等选择不同的入口函数。

函数方执行的时候,我们通常可以理解为有一个解析的过程,这里的函数要么被解释器执行(JIT方式),要么直接运行已经编译好的机器码(AOT)。

我们可以看到,解释器执行入口函数为EnterInterpreterFromInvoke方法,或者直接通过“蹦床”函数,art_quick_invoke_stub( 无static 修饰 ) 或者art_quick_invoke_static_stub( 有static 修饰 去找到被执行的方法的机器码。因此如果我们有想要分析art虚拟机方法运行的机制时,这几个函数就是关键的切入点。

回到我们主题JNI函数,在无static修饰时,默认就走art_quick_invoke_stub 方法去寻找JNI函数的具体实现

我们就拿一个普通的JNI函数举例子,比如以下方法testJNI

external fun testJNI()

art_quick_invoke_stub 这个函数就比较有意思了,它会通过调用quick_invoke_reg_setup 方法准备好寄存器的一些写入后,直接调用汇编代码art_quick_invoke_stub_internal。

// Assembly stub that does the final part of the up-call into Java.
extern "C" void art_quick_invoke_stub_internal(ArtMethod*, uint32_t*, uint32_t,
                                               Thread* self, JValue* result, uint32_t, uint32_t*,
                                               uint32_t*);

汇编代码是平台相关的代码,比如x86与arm上实现的汇编肯定也都不一样,这里的实现根据指令集的不同而不同,以arm架构举例子:

这个方法之后的调用链中,其中一个重要的作用就是通过ART_METHOD_QUICK_CODE_OFFSET_32 判断是不是jni函数分别调用不同的解析函数

ldr    ip, [r0, #ART_METHOD_QUICK_CODE_OFFSET_32]  @ get pointer to the code
    blx    ip                              @ call the method

这里我们停一下,类方法的解析过程其实是一个较为耗时的过程,同时因为要与机器码打交道,不同cpu架构的机器码肯定也就不一样,因此这个方法里面用了大量的汇编函数进行桥接(上文我们看的就是arm指令集),这也是art中的特点,后面如果有新的指令集需要支持,那么这里肯定也会加上对应的指令集支持。

如果是JNI函数,那么就进入下一步,查找JNI函数的实现,这里通常也会用一个“蹦床”,我们下文会说到。至此,我们总结一下整个JNI函数相关的调用过程:

image.png

JNI入口函数生成

为什么ART虚拟机需要进行JNI函数入口的生成呢?这是因为我们类被加载的时候,方法如果没有被调用,那么它其实还处于未解析的状态(如果类解析同时也把jni函数解析,那么会拖慢类加载速度,同时大部分方法也不一定被使用),因此大部分系统其实就是方法用时再解析,这也是为什么Invoke调用之后才进行解析。JNI特别的点是,它的实现其实是放在native代码中的,比如某个so。因此JNI入口函数其实就是要把Java世界中的这个jni函数符号与native代码实现进行绑定。

artQuickGenericJniTrampoline 其实就会去找,当前的data字段,如果当前jni函数已经是被解析了,那么其实jni的实现就会放在data字段中,这个时候只需要调用data字段即可,因为data字段会在解析时存放jni函数的实现。

  //jni函数存放点
  void* GetEntryPointFromJni() const {
    DCHECK(IsNative());
    return GetEntryPointFromJniPtrSize(kRuntimePointerSize);
  }

  ALWAYS_INLINE void* GetEntryPointFromJniPtrSize(PointerSize pointer_size) const {
    return GetDataPtrSize(pointer_size);
  }

  ALWAYS_INLINE void* GetDataPtrSize(PointerSize pointer_size) const {
    DCHECK(IsImagePointerSize(pointer_size));
    return GetNativePointer<void*>(DataOffset(pointer_size), pointer_size);
  }

如果没有被解析,即调用artFindNativeMethod 方法,去进行JNI函数的解析,最终通过**artFindNativeMethodRunnable**方法,通过我们最熟知的RegisterNatives 的方式进行加载(见下文解释,ClassLinker::RegisterNative 方法),相关代码如下:

// Used by the JNI dlsym stub to find the native method to invoke if none is registered.
extern "C" const void* artFindNativeMethodRunnable(Thread* self)
    REQUIRES_SHARED(Locks::mutator_lock_) {
  ...
  return class_linker->RegisterNative(self, method, native_code);
}
const void* ClassLinker::RegisterNative(
    Thread* self, ArtMethod* method, const void* native_method) {
  ...
  Runtime* runtime = Runtime::Current();
  runtime->GetRuntimeCallbacks()->RegisterNativeMethod(method,
                                                       native_method,
                                                       /*out*/&new_native_method);
  这里区分了注解加载场景,比如CriticalNative                                              
  if (method->IsCriticalNative()) {
    MutexLock lock(self, critical_native_code_with_clinit_check_lock_);
    // Remove old registered method if any.
    auto it = critical_native_code_with_clinit_check_.find(method);
    if (it != critical_native_code_with_clinit_check_.end()) {
      critical_native_code_with_clinit_check_.erase(it);
    }
    加载完成后把data字段设置为native方法的函数地址
    
    if (method->GetDeclaringClass()->IsVisiblyInitialized()) {
      method->SetEntryPointFromJni(new_native_method);
    } else {
      critical_native_code_with_clinit_check_.emplace(method, new_native_method);
    }
  } else {
    method->SetEntryPointFromJni(new_native_method);
  }
  return new_native_method;
}

注意,JNIEnv 的RegisterNatives方法最终也是通过classlinker的RegisterNative实现的

RegisterNatives 方法中
const void* final_function_ptr = class_linker->RegisterNative(soa.Self(), m, fnPtr);
      UNUSED(final_function_ptr);

至此,就完成了整个JNI函数的解析过程,RegisterNative方法就完成了JNI绑定。

我们可以发现,其实ART在实现JNI的本质就是帮我们做了(native or external)函数与本地方法的绑定,同时整个jni函数的查找过程,其实就是把native函数填充在data字段

struct PtrSizedFields {
    设置到这里
    // native method: pointer to the JNI function registered to this method
    void* data_;
   ...
} ptr_sized_fields_;

后续jni调用只需要调用data字段函数地址即可,但是为了提高性能所以实现起来就会比较绕。

JNI Hook中时序引起的问题

从上面我们了解到了整个JNI函数机制的加载过程,而在JNI Hook领域,事情并没有那么简单。我们需要针对JNI函数进行替换的话,就得考虑到JNI函数的解析时机。如果对JNI Hook的方法不太清楚的话,可以看一下我之前的这篇文章JNI函数Hook实战

在JNI Hook 领域中,有可能我们调用Hook函数的时候,此时的JNI函数还没有被解析,那么我们如果直接通过下面函数进行方法替换的时候,就会出现问题:

int hook_jni(JNIEnv *env, jobject method, void *new_entrance, void **origin_entrance) {
    // 这段代码做了一件事:就是替换data字段的内容,通过替换内容达到jni函数替换目的
    if (jni_entrance_index == -1) {
        return -1;
    }
    // ArtMethod 中,按照内存顺路遍历,找到jni函数的存放点
    void **target_art_method = get_art_method(env, method);
    if (target_art_method[jni_entrance_index] == new_entrance) {
        return 0;
    }
   
    *origin_entrance = target_art_method[jni_entrance_index];
    target_art_method[jni_entrance_index] = new_entrance;
    return 1;
}

origin_entrance拿到的,就并不是我们想要的被Hook的函数,因为此时JNI函数还没有被解析,那么它拿到的其实是art_jni_dlsym_lookup_stub函数的地址,可以见下面初始化流程:

static void DefaultInitEntryPoints(JniEntryPoints* jpoints,
                                   QuickEntryPoints* qpoints,
                                   bool monitor_jni_entry_exit) {
  // 设置蹦床地址:因为jni函数可以延迟再绑定
  jpoints->pDlsymLookup = reinterpret_cast<void*>(art_jni_dlsym_lookup_stub);
  jpoints->pDlsymLookupCritical = reinterpret_cast<void*>(art_jni_dlsym_lookup_critical_stub);

ART初始化时,会调用DefaultInitEntryPoints 方法。DefaultInitEntryPoints会被InitEntryPoints方法调起,它也是一个平台相关函数:ART初始化过程中的Runtime::Init函数会调用InitEntryPoints,其实就是为了给JNI函数设置一个待解析的“蹦床”。

art_jni_dlsym_lookup_stub函数的实现也是汇编代码,我们以arm64位代码解释,其实它最终也是调用artFindNativeMethod进行JNI函数的解析。artFindNativeMethod我们上文已经分析过了,这里就不再赘述。

     // 通过蹦床查找真正的jni函数实现,artFindNativeMethod会复制把ArtMethod的data字段填充为正确解析的jni的native函数实现
    /*
     * Jni dlsym lookup stub.
     */
    .extern artFindNativeMethod
    .extern artFindNativeMethodRunnable
ENTRY art_jni_dlsym_lookup_stub
    // spill regs.
    SAVE_ALL_ARGS_INCREASE_FRAME 2 * 8
    stp   x29, x30, [sp, ALL_ARGS_SIZE]
    .cfi_rel_offset x29, ALL_ARGS_SIZE
    .cfi_rel_offset x30, ALL_ARGS_SIZE + 8
    add   x29, sp, ALL_ARGS_SIZE

    mov x0, xSELF   // pass Thread::Current()
    // Call artFindNativeMethod() for normal native and artFindNativeMethodRunnable()
    // for @FastNative or @CriticalNative.
    ldr   xIP0, [x0, #THREAD_TOP_QUICK_FRAME_OFFSET]      // uintptr_t tagged_quick_frame
    bic   xIP0, xIP0, #TAGGED_JNI_SP_MASK                 // ArtMethod** sp
    ldr   xIP0, [xIP0]                                    // ArtMethod* method
    ldr   xIP0, [xIP0, #ART_METHOD_ACCESS_FLAGS_OFFSET]   // uint32_t access_flags
    mov   xIP1, #(ACCESS_FLAGS_METHOD_IS_FAST_NATIVE | ACCESS_FLAGS_METHOD_IS_CRITICAL_NATIVE)
    tst   xIP0, xIP1
    b.ne  .Llookup_stub_fast_or_critical_native
    bl    artFindNativeMethod
    b     .Llookup_stub_continue
    .Llookup_stub_fast_or_critical_native:
    bl    artFindNativeMethodRunnable

因此,我们在JNI函数还未被解析的时候,进行JNI hook的话,那么就会再次调用origin_entrance方法的时候,就会进入死循环导致问题的发生。原因是origin_entrance此时并不是被解析后的jni函数native实现,而是art_jni_dlsym_lookup_stub 函数,因此调用就形成了循环查找。

根据上文JNI函数加载流程,我们可以总结未被解析的场景有以下几种:

so并没有主动调用RegisterNatives方法或者该JNI还没被调用一次( 没被调用就不会触发ArtMethod Invoke解析场景 )。

如何解决时序问题

此类问题发生的原因就是JNI函数还没有被绑定就调用了JNI Hook,这样导致的问题是data字段其实还没有被赋值为真正jni函数地址时就被修改为hook函数,那么如何解决此类问题呢?其实我们只需要把JNI Hook的时机延后即可。其中一个比较好的延后的时机就是,在方法进行RegisterNatives调用的时候,我们再进行JNI Hook即可。(当然,这种方式只适合动态注册场景,读者可以思考为什么? 静态注册监听方式有其他方法,比如插桩调用监听或者利用ClassLinker::RegisterNative 的方式注册,比如JVMTI就是这么实现的,这种更全面的方式之后有机会再分享给大家)

思路有了,因为RegisterNatives是在JNIEnv里面的,在C语言调用,它是一个叫做JNINativeInterface的结构体,在C++中它是一个包装类结构体_JNIEnv

#if defined(__cplusplus)
typedef _JNIEnv JNIEnv;
typedef _JavaVM JavaVM;
#else
typedef const struct JNINativeInterface* JNIEnv;
typedef const struct JNIInvokeInterface* JavaVM;
#endif

因此我们只需要更改加载时so时JNI Env的RegisterNatives,替换为我们自定义的RegisterNatives即可实现对RegisterNatives方法的监控,以C语言代码为例子就是如下,基本思路就是通过偏移找到方法地址然后动态替换

//找到RegisterNatives 函数并把其地址替换为我们的hook函数:
original_functions = *env;
int offset = offsetof(struct JNINativeInterface, RegisterNatives);
void **target = (void **) (((char *) original_functions) + offset);
uintptr_t start_addr = PAGE_START((uintptr_t) (target));
uintptr_t end_addr = PAGE_START((uintptr_t) target + sizeof(uintptr_t) - 1) + PAGE_SIZE;
size_t size = end_addr - start_addr;

if (mprotect((void *) start_addr, size, PROT_WRITE | PROT_READ) == -1) {
    __android_log_print(ANDROID_LOG_ERROR, "jnihook", "%s", "mprotect fail");
}
backup = *target;
//替换自定义RegisterNatives监控函数
*target = hook_jni_RegisterNatives;
if (mprotect((void *) start_addr, size, PROT_READ) == -1) {
    __android_log_print(ANDROID_LOG_ERROR, "jnihook", "%s", "mprotect fail");
}

当然,还有个问题是我们如何识别哪些JNI函数是未被加载的,这里我们可以直接通过symbol 比对的方式,当data字段的函数地址是art_jni_dlsym_lookup_stub函数地址(见上文分析)或者为NULL(防止当前art_jni_dlsym_lookup_stub还未被解析提前调用hook)时,那么它其实是一个未被解析的JNI函数。这样我们就能够识别出来哪些JNI Hook操作是能够及时生效的,哪些是需要延后处理的

void *handle = xdl_open("libart.so", XDL_DEFAULT);
// 解析出来蹦床函数地址
jni_stub = find_symbol(handle, "art_jni_dlsym_lookup_stub");

int hook_jni(JNIEnv *env, jobject method, void *new_entrance, void **origin_entrance) {
    if (jni_entrance_index == -1) {
        return -1;
    }
    void **target_art_method = get_art_method(env, method);
    if (target_art_method[jni_entrance_index] == new_entrance) {
        return 0;
    }
    //识别出来当前的指针还是蹦床函数
    if (target_art_method[jni_entrance_index] == jni_stub ||
        target_art_method[jni_entrance_index] == NULL) {
        // 当前jni函数还未加载,可以注册RegisterNative监听
        return -2;
    }
    *origin_entrance = target_art_method[jni_entrance_index];
    target_art_method[jni_entrance_index] = new_entrance;
    return 1;
}

这里的关键是,查找ArtMethod中的data字段,如果当前的指针还是处于未被解析阶段(函数指针为NULL或者为蹦床函数art_jni_dlsym_lookup_stub),则提前终止函数hook,避免hook失败。

总结

通过JNI函数的在ART的解析过程学习,相信读者对ART虚拟机的方法解析更近一步,通过对源码的学习,我们可以在一些特定的解决方案中找到思路,本例子就以JNI Hook的时序问题作为引子,让大家能够更加深入JNI相关的解析。本文涉及的相关源码均已开放在github JNIHook当中,希望能对你有所帮助。

我是Pika,一个神奇的移动端开发,我们下期再见~